From ebead9e98ccd82301b3acfe1c9bc8e92f5f60404 Mon Sep 17 00:00:00 2001 From: A Date: Tue, 21 Jul 2026 15:46:24 +0800 Subject: [PATCH] feat: sync Telegram Login OIDC provider --- .env.example | 18 + cmd/bots/bedolagaformat/README.md | 60 +- cmd/bots/bedolagaformat/demo.py | 123 +- cmd/bots/bedolagaformat/login_demo.py | 462 ++++++ cmd/bots/bedolagaformat/requirements.txt | 1 + cmd/bots/bedolagaformat/test_demo.py | 39 + cmd/bots/bedolagaformat/test_login_demo.py | 154 ++ cmd/telegramloginkeygen/main.go | 391 +++++ cmd/telegramloginkeygen/main_test.go | 109 ++ cmd/telesrv/main.go | 91 ++ .../0125_telegram_login_oidc.down.sql | 14 + .../0125_telegram_login_oidc.up.sql | 253 +++ go.mod | 10 + go.sum | 20 + internal/app/bots/botfather.go | 223 ++- internal/app/bots/botfather_login_test.go | 100 ++ internal/app/bots/service.go | 12 + internal/app/telegramlogin/crypto.go | 131 ++ internal/app/telegramlogin/jose.go | 406 +++++ .../app/telegramlogin/jose_es256k_disabled.go | 5 + .../jose_es256k_disabled_test.go | 17 + .../app/telegramlogin/jose_es256k_enabled.go | 5 + .../telegramlogin/jose_es256k_enabled_test.go | 57 + internal/app/telegramlogin/jose_test.go | 188 +++ internal/app/telegramlogin/keyfiles.go | 186 +++ internal/app/telegramlogin/keyfiles_test.go | 86 ++ internal/app/telegramlogin/native.go | 80 + internal/app/telegramlogin/service.go | 1374 +++++++++++++++++ internal/app/telegramlogin/service_test.go | 404 +++++ internal/app/telegramlogin/url.go | 141 ++ internal/app/telegramlogin/url_test.go | 115 ++ internal/botapi/inline.go | 65 +- internal/botapi/projection.go | 12 + internal/botapi/server_test.go | 13 + internal/config/config.go | 78 + internal/config/config_test.go | 81 +- internal/domain/message_markup.go | 50 +- internal/domain/message_markup_test.go | 4 + internal/domain/telegram_login.go | 506 ++++++ internal/domain/telegram_login_test.go | 112 ++ internal/rpc/account.go | 10 +- internal/rpc/botapi_gateway.go | 18 + internal/rpc/bots_inline.go | 3 + internal/rpc/convert_markup.go | 125 +- internal/rpc/convert_markup_test.go | 20 + internal/rpc/deps.go | 19 + internal/rpc/messages_bot_no_state.go | 13 + internal/rpc/messages_edit.go | 7 + internal/rpc/messages_register.go | 12 + internal/rpc/messages_webview.go | 9 + internal/rpc/telegram_login.go | 434 ++++++ internal/rpc/telegram_login_rpc_test.go | 380 +++++ internal/store/memory/bot.go | 3 + internal/store/memory/telegram_login.go | 721 +++++++++ internal/store/memory/telegram_login_test.go | 320 ++++ ...ft_lifecycle_migration_integration_test.go | 4 +- internal/store/postgres/telegram_login.go | 1106 +++++++++++++ .../telegram_login_integration_test.go | 434 ++++++ internal/store/telegram_login.go | 49 + internal/telegramloginhttp/handler.go | 748 +++++++++ internal/telegramloginhttp/handler_test.go | 675 ++++++++ internal/telegramloginhttp/sdk.go | 90 ++ internal/web/server.go | 15 + 63 files changed, 11374 insertions(+), 37 deletions(-) create mode 100644 cmd/bots/bedolagaformat/login_demo.py create mode 100644 cmd/bots/bedolagaformat/test_login_demo.py create mode 100644 cmd/telegramloginkeygen/main.go create mode 100644 cmd/telegramloginkeygen/main_test.go create mode 100644 deploy/migrations/0125_telegram_login_oidc.down.sql create mode 100644 deploy/migrations/0125_telegram_login_oidc.up.sql create mode 100644 internal/app/bots/botfather_login_test.go create mode 100644 internal/app/telegramlogin/crypto.go create mode 100644 internal/app/telegramlogin/jose.go create mode 100644 internal/app/telegramlogin/jose_es256k_disabled.go create mode 100644 internal/app/telegramlogin/jose_es256k_disabled_test.go create mode 100644 internal/app/telegramlogin/jose_es256k_enabled.go create mode 100644 internal/app/telegramlogin/jose_es256k_enabled_test.go create mode 100644 internal/app/telegramlogin/jose_test.go create mode 100644 internal/app/telegramlogin/keyfiles.go create mode 100644 internal/app/telegramlogin/keyfiles_test.go create mode 100644 internal/app/telegramlogin/native.go create mode 100644 internal/app/telegramlogin/service.go create mode 100644 internal/app/telegramlogin/service_test.go create mode 100644 internal/app/telegramlogin/url.go create mode 100644 internal/app/telegramlogin/url_test.go create mode 100644 internal/domain/telegram_login.go create mode 100644 internal/domain/telegram_login_test.go create mode 100644 internal/rpc/telegram_login.go create mode 100644 internal/rpc/telegram_login_rpc_test.go create mode 100644 internal/store/memory/telegram_login.go create mode 100644 internal/store/memory/telegram_login_test.go create mode 100644 internal/store/postgres/telegram_login.go create mode 100644 internal/store/postgres/telegram_login_integration_test.go create mode 100644 internal/store/telegram_login.go create mode 100644 internal/telegramloginhttp/handler.go create mode 100644 internal/telegramloginhttp/handler_test.go create mode 100644 internal/telegramloginhttp/sdk.go diff --git a/.env.example b/.env.example index 4d388357..c706c601 100644 --- a/.env.example +++ b/.env.example @@ -159,6 +159,24 @@ TELESRV_STICKER_SEED_DIR=data/sticker-seed # through nginx; public canonical URLs use TELESRV_PUBLIC_BASE_URL. TELESRV_PUBLIC_LINK_WEB_ADDR=127.0.0.1:2401 +# Self-hosted Telegram Login / OpenID Connect. The provider is mounted on the +# public-link listener above. Keep disabled until all three local key files +# have been generated with `go run ./cmd/telegramloginkeygen -mode init`. +TELESRV_TELEGRAM_LOGIN_ENABLE=false +TELESRV_TELEGRAM_LOGIN_ISSUER=https://telesrv.net +TELESRV_TELEGRAM_LOGIN_ALLOW_LOOPBACK_HTTP=false +TELESRV_TELEGRAM_LOGIN_SIGNING_KEYS_FILE=data/telegram-login/signing-keys.json +TELESRV_TELEGRAM_LOGIN_CODE_KEYS_FILE=data/telegram-login/code-keys.json +TELESRV_TELEGRAM_LOGIN_SECRET_PEPPER_FILE=data/telegram-login/client-secret-pepper +TELESRV_TELEGRAM_LOGIN_REQUEST_TTL=5m +TELESRV_TELEGRAM_LOGIN_CODE_TTL=2m +TELESRV_TELEGRAM_LOGIN_ID_TOKEN_TTL=1h +# Trust only the loopback nginx hop in the documented single-host deployment. +TELESRV_TELEGRAM_LOGIN_TRUSTED_PROXY_CIDRS=127.0.0.1/32,::1/128 +TELESRV_TELEGRAM_LOGIN_RETENTION=168h +TELESRV_TELEGRAM_LOGIN_SWEEP_INTERVAL=5m +TELESRV_TELEGRAM_LOGIN_SWEEP_BATCH=500 + # AI compose for TDesktop/Android input box rewrite/polish. # The local provider is deterministic and does not call external services. TELESRV_AI_ENABLED=true diff --git a/cmd/bots/bedolagaformat/README.md b/cmd/bots/bedolagaformat/README.md index 155cff04..1eea2f53 100644 --- a/cmd/bots/bedolagaformat/README.md +++ b/cmd/bots/bedolagaformat/README.md @@ -1,4 +1,4 @@ -# Bedolaga formatted-text demo +# Bedolaga formatted-text + Telegram Login demo 这个 demo 复刻 Bedolaga 的 Bot 工厂关键配置: @@ -67,3 +67,61 @@ $env:TELESRV_BOT_API_SERVER = "http://127.0.0.1:8081" `--base-url` 只接受 API server 根地址,不要追加 `/bot`。脚本不会打印 token,也不会 把 token 写入文件。 + +## Telegram Login 全链路 + +同一个 demo 还提供 `/logindemo`,覆盖三个互相独立的公开契约: + +1. Bot API `login_url` 按钮 → TDesktop/Android 的 + `messages.requestUrlAuth/acceptUrlAuth` → legacy HMAC 回调; +2. telesrv 本地 `/telegram-login.js` → popup `postMessage` → JWKS 验签; +3. 服务端 Authorization Code + PKCE S256 → `/token` Basic Client Secret → + JWKS 验签和 `issuer/audience/nonce/subject` 复核。 + +先在 telesrv 的 @BotFather 中对目标 bot 运行 `/setlogin`。选择 bot 后逐条登记 demo +的精确 origin 和 callback(本机示例): + +```text +add origin http://127.0.0.1:3000 +add redirect http://127.0.0.1:3000/oauth/callback +enable +``` + +`/setlogin` 首次创建 client 时只展示一次 OIDC Client Secret;不要写进仓库。可用 +`/logininfo` 查看 Client ID 和登记结果,或用 `/resetloginsecret` 轮换 secret。 +loopback HTTP 仅应配合 telesrv 的显式开发开关使用;testserver/生产必须换成精确 +HTTPS origin。 + +把一次性 secret 和 Client ID 放入进程环境,再启动: + +```powershell +$env:TELESRV_BOT_LOGIN_DEMO = "1" +$env:TELESRV_BOT_LOGIN_ISSUER = "http://127.0.0.1:2401" +$env:TELESRV_BOT_LOGIN_CLIENT_ID = "" +$env:TELESRV_BOT_LOGIN_CLIENT_SECRET = "" +$env:TELESRV_BOT_LOGIN_PUBLIC_URL = "http://127.0.0.1:3000" +$env:TELESRV_BOT_LOGIN_LISTEN = "127.0.0.1:3000" + +& "$env:TEMP\telesrv-bedolaga-demo-venv\Scripts\python.exe" ` + .\cmd\bots\bedolagaformat\demo.py --drop-pending --login-demo +``` + +向 bot 发送 `/logindemo`。第一颗按钮必须出现 Telegram 客户端原生授权确认框,批准 +后网页显示 `login_url HMAC verified`;第二颗按钮打开测试页,可分别运行 JS SDK popup +和 Authorization Code + PKCE。页面只展示验签后的 claims,不展示 access token 或 +Client Secret。省略 `TELESRV_BOT_LOGIN_CLIENT_SECRET` 时仍可验证 JS popup,但服务端 +code flow 会明确禁用。 + +demo 的 flow/state/nonce 只保存在单进程内存中,带 10 分钟过期和 256 条上限,专用于 +本地与 testserver 端到端验证,不是生产 relying-party 实现。官方 iOS/Android SDK +目前把 `https://oauth.telegram.org` 写死;验证自建 issuer 时需使用项目记录的最小 +base-URL patch 或等价测试构建,不能把官方生产 SDK 未修改的结果误判为自建服务结果。 + +测试命令: + +```powershell +& "$env:TEMP\telesrv-bedolaga-demo-venv\Scripts\python.exe" ` + .\cmd\bots\bedolagaformat\test_demo.py -v +& "$env:TEMP\telesrv-bedolaga-demo-venv\Scripts\python.exe" ` + .\cmd\bots\bedolagaformat\test_login_demo.py -v +``` diff --git a/cmd/bots/bedolagaformat/demo.py b/cmd/bots/bedolagaformat/demo.py index d339a8db..adc6b87e 100644 --- a/cmd/bots/bedolagaformat/demo.py +++ b/cmd/bots/bedolagaformat/demo.py @@ -27,15 +27,27 @@ from aiogram.types import ( InlineKeyboardButton, InlineKeyboardMarkup, InputRichMessage, + LoginUrl, Message, ) +from login_demo import ( + LoginDemoConfig, + LoginDemoServer, + normalize_web_base, + parse_listen, +) + LOG = logging.getLogger("bedolagaformat") MARKER_RE = re.compile(r"^[A-Za-z0-9-]{1,64}$") MARKDOWN_V2_RESERVED_RE = re.compile(r"([_\*\[\]\(\)~`>#+\-=|{}\.!\\])") +def env_flag(name: str) -> bool: + return os.getenv(name, "").strip().lower() in {"1", "true", "yes", "on"} + + @dataclass(frozen=True) class FormatSample: name: str @@ -121,6 +133,34 @@ def parse_args() -> argparse.Namespace: parser.add_argument("--polling-timeout", type=int, default=10) parser.add_argument("--marker", default=default_marker()) parser.add_argument("--log-level", default="INFO") + parser.add_argument( + "--login-demo", + action="store_true", + default=env_flag("TELESRV_BOT_LOGIN_DEMO"), + help="serve and send the Bedolaga Telegram Login/OIDC demo", + ) + parser.add_argument( + "--login-issuer", + default=os.getenv("TELESRV_BOT_LOGIN_ISSUER", "http://127.0.0.1:2401"), + ) + parser.add_argument( + "--login-client-id", + default=os.getenv("TELESRV_BOT_LOGIN_CLIENT_ID", ""), + ) + parser.add_argument( + "--login-client-secret", + default=os.getenv("TELESRV_BOT_LOGIN_CLIENT_SECRET", ""), + help="confidential OIDC secret; never printed (optional for JS-only demo)", + ) + parser.add_argument( + "--login-public-url", + default=os.getenv("TELESRV_BOT_LOGIN_PUBLIC_URL", "http://127.0.0.1:3000"), + help="registered origin where this demo is reachable", + ) + parser.add_argument( + "--login-listen", + default=os.getenv("TELESRV_BOT_LOGIN_LISTEN", "127.0.0.1:3000"), + ) args = parser.parse_args() if not args.token: parser.error("missing --token or TELESRV_BOT_TOKEN") @@ -132,6 +172,24 @@ def parse_args() -> argparse.Namespace: parser.error("--marker must contain 1-64 ASCII letters, digits, or hyphens") if not 0 <= args.polling_timeout <= 50: parser.error("--polling-timeout must be between 0 and 50") + args.login_config = None + if args.login_demo: + if not re.fullmatch(r"[0-9]{1,64}", args.login_client_id): + parser.error("--login-demo requires a numeric --login-client-id") + try: + issuer = normalize_web_base(args.login_issuer, name="login issuer") + public_url = normalize_web_base(args.login_public_url, name="login public URL") + listen_host, listen_port = parse_listen(args.login_listen) + except ValueError as exc: + parser.error(str(exc)) + args.login_config = LoginDemoConfig( + issuer=issuer, + client_id=args.login_client_id, + client_secret=args.login_client_secret, + public_url=public_url, + listen_host=listen_host, + listen_port=listen_port, + ) return args @@ -207,6 +265,39 @@ def rich_menu_keyboard() -> InlineKeyboardMarkup: ) +def login_demo_keyboard(config: LoginDemoConfig) -> InlineKeyboardMarkup: + """Exercise both Telegram's login_url button and a plain OIDC page URL.""" + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text="Log in with Telegram", + login_url=LoginUrl( + url=config.public_url + "/", + forward_text="Bedolaga Login", + request_write_access=True, + ), + ) + ], + [InlineKeyboardButton(text="Open OIDC test page", url=config.public_url + "/")], + ] + ) + + +async def send_login_demo(bot: Bot, chat_id: int, marker: str, config: LoginDemoConfig) -> int: + message = await bot.send_message( + chat_id=chat_id, + text=( + f"{marker} Telegram Login\n" + "The first button validates Bot API login_url; " + "the second page validates the local JS SDK and OIDC + PKCE." + ), + reply_markup=login_demo_keyboard(config), + ) + LOG.info("sent Telegram Login demo chat_id=%s message_id=%s", chat_id, message.message_id) + return message.message_id + + def is_rich_media_retry_error(exc: TelegramBadRequest) -> bool: message = str(exc).lower() return "webpage_" in message or "media_empty" in message or "media_invalid" in message @@ -259,7 +350,7 @@ async def send_rich_suite(bot: Bot, chat_id: int, marker: str) -> list[int]: return ids -def build_dispatcher(marker: str) -> Dispatcher: +def build_dispatcher(marker: str, login_config: LoginDemoConfig | None = None) -> Dispatcher: router = Router(name="telesrv-bedolaga-format") @router.message(CommandStart()) @@ -291,6 +382,22 @@ def build_dispatcher(marker: str) -> Dispatcher: ids, ) + @router.message(Command("logindemo")) + async def login_demo(message: Message) -> None: + if login_config is None: + await message.answer( + "Telegram Login demo is disabled. Start this program with " + "--login-demo." + ) + return + message_id = await send_login_demo(message.bot, message.chat.id, marker, login_config) + LOG.info( + "handled /logindemo chat_id=%s incoming_message_id=%s sent_message_id=%s", + message.chat.id, + message.message_id, + message_id, + ) + dispatcher = Dispatcher() dispatcher.include_router(router) return dispatcher @@ -298,7 +405,10 @@ def build_dispatcher(marker: str) -> Dispatcher: async def run(args: argparse.Namespace) -> None: bot = create_bot(args.token, args.base_url) + login_server = LoginDemoServer(args.login_config, args.token) if args.login_config else None try: + if login_server is not None: + await login_server.start() me = await bot.get_me() LOG.info( "authenticated bot_id=%s username=@%s bot_api=%s marker=%s", @@ -312,12 +422,17 @@ async def run(args: argparse.Namespace) -> None: await send_format_suite(bot, args.send_chat_id, args.marker) if args.rich_menu or args.rich_only: await send_rich_suite(bot, args.send_chat_id, args.marker) + if args.login_config is not None: + await send_login_demo(bot, args.send_chat_id, args.marker, args.login_config) if args.send_only: return await bot.delete_webhook(drop_pending_updates=args.drop_pending) - dispatcher = build_dispatcher(args.marker) - LOG.info("polling started; send /start, /formatdemo or /richdemo to @%s", me.username or me.id) + dispatcher = build_dispatcher(args.marker, args.login_config) + LOG.info( + "polling started; send /start, /formatdemo, /richdemo or /logindemo to @%s", + me.username or me.id, + ) await dispatcher.start_polling( bot, allowed_updates=["message"], @@ -325,6 +440,8 @@ async def run(args: argparse.Namespace) -> None: close_bot_session=False, ) finally: + if login_server is not None: + await login_server.close() await bot.session.close() diff --git a/cmd/bots/bedolagaformat/login_demo.py b/cmd/bots/bedolagaformat/login_demo.py new file mode 100644 index 00000000..c0cae522 --- /dev/null +++ b/cmd/bots/bedolagaformat/login_demo.py @@ -0,0 +1,462 @@ +"""Local Bedolaga Telegram Login/OIDC relying-party demo. + +This is deliberately a relying party, not a shortcut into telesrv internals. It +validates the three public contracts used by a Bedolaga-style bot: + +* Bot API ``login_url`` legacy HMAC callbacks; +* the self-hosted Telegram Login JavaScript SDK ``post_message`` response; and +* confidential authorization-code + PKCE followed by JWKS ID-token validation. + +The demo keeps its short-lived browser flows in memory. It is intended for +local/end-to-end verification only and must not be used as a production login +backend. +""" + +from __future__ import annotations + +import asyncio +import base64 +from dataclasses import dataclass +import hashlib +import hmac +import html +import json +import logging +import secrets +import time +from typing import Any +from urllib.parse import urlencode, urlsplit + +from aiohttp import BasicAuth, ClientSession, ClientTimeout, web +import jwt + + +LOG = logging.getLogger("bedolagaformat.login") +FLOW_TTL_SECONDS = 10 * 60 +MAX_PENDING_FLOWS = 256 +LEGACY_AUTH_MAX_AGE_SECONDS = 15 * 60 +OIDC_ALGORITHMS = ("RS256", "ES256", "EdDSA", "ES256K") + + +@dataclass(frozen=True) +class LoginDemoConfig: + issuer: str + client_id: str + client_secret: str + public_url: str + listen_host: str + listen_port: int + + @property + def redirect_uri(self) -> str: + return self.public_url + "/oauth/callback" + + @property + def origin(self) -> str: + parsed = urlsplit(self.public_url) + return f"{parsed.scheme}://{parsed.netloc}" + + @property + def code_flow_enabled(self) -> bool: + return bool(self.client_secret) + + +@dataclass(frozen=True) +class PendingFlow: + nonce: str + expires_at: float + code_verifier: str = "" + + +def _is_loopback(host: str | None) -> bool: + return host in {"127.0.0.1", "::1", "localhost"} + + +def normalize_web_base(value: str, *, name: str) -> str: + raw = value.strip().rstrip("/") + parsed = urlsplit(raw) + if ( + parsed.scheme not in {"http", "https"} + or not parsed.netloc + or parsed.username is not None + or parsed.password is not None + or parsed.query + or parsed.fragment + or parsed.path not in {"", "/"} + ): + raise ValueError(f"{name} must be an absolute origin without path, query, or fragment") + if parsed.scheme != "https" and not _is_loopback(parsed.hostname): + raise ValueError(f"{name} must use HTTPS except on loopback") + return f"{parsed.scheme}://{parsed.netloc}" + + +def parse_listen(value: str) -> tuple[str, int]: + parsed = urlsplit("//" + value.strip()) + try: + port = parsed.port + except ValueError as exc: + raise ValueError("login demo listen port is invalid") from exc + if not parsed.hostname or port is None or not 1 <= port <= 65535: + raise ValueError("login demo listen address must be host:port") + return parsed.hostname, port + + +def base64url(data: bytes) -> str: + return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii") + + +def generate_pkce() -> tuple[str, str]: + verifier = base64url(secrets.token_bytes(32)) + challenge = base64url(hashlib.sha256(verifier.encode("ascii")).digest()) + return verifier, challenge + + +def verify_legacy_login_query( + query: dict[str, str], bot_token: str, *, now: int | None = None +) -> dict[str, str]: + """Verify Telegram's legacy login_url data-check string. + + Only the documented signed identity fields participate. Query parameters + already present on the relying-party URL are intentionally excluded. + """ + + signed_names = ( + "auth_date", + "first_name", + "id", + "last_name", + "photo_url", + "username", + ) + supplied_hash = query.get("hash", "") + if len(supplied_hash) != 64: + raise ValueError("missing legacy login signature") + values = {name: query[name] for name in signed_names if name in query} + if not all(values.get(name) for name in ("auth_date", "first_name", "id")): + raise ValueError("incomplete legacy login payload") + try: + auth_date = int(values["auth_date"]) + user_id = int(values["id"]) + except ValueError as exc: + raise ValueError("invalid legacy login payload") from exc + current = int(time.time()) if now is None else now + if user_id <= 0 or auth_date > current + 30 or current - auth_date > LEGACY_AUTH_MAX_AGE_SECONDS: + raise ValueError("expired legacy login payload") + data_check = "\n".join(f"{name}={values[name]}" for name in sorted(values)) + secret_key = hashlib.sha256(bot_token.encode("utf-8")).digest() + actual = hmac.new(secret_key, data_check.encode("utf-8"), hashlib.sha256).hexdigest() + if not hmac.compare_digest(actual, supplied_hash.lower()): + raise ValueError("invalid legacy login signature") + return values + + +def _safe_claims(claims: dict[str, Any]) -> dict[str, Any]: + allowed = ( + "iss", + "aud", + "sub", + "iat", + "exp", + "nonce", + "id", + "name", + "given_name", + "family_name", + "preferred_username", + "picture", + "phone_number", + "phone_number_verified", + ) + return {key: claims[key] for key in allowed if key in claims} + + +class LoginDemoServer: + def __init__(self, config: LoginDemoConfig, bot_token: str) -> None: + self.config = config + self.bot_token = bot_token + self._flows: dict[str, PendingFlow] = {} + self._flow_lock = asyncio.Lock() + self._http: ClientSession | None = None + self._runner: web.AppRunner | None = None + + async def start(self) -> None: + timeout = ClientTimeout(total=10) + self._http = ClientSession(timeout=timeout) + app = web.Application(client_max_size=64 * 1024) + app.add_routes( + [ + web.get("/", self.root), + web.get("/login/code", self.start_code_flow), + web.get("/oauth/callback", self.code_callback), + web.post("/verify-popup", self.verify_popup), + web.get("/healthz", self.health), + ] + ) + self._runner = web.AppRunner(app, access_log=None) + await self._runner.setup() + site = web.TCPSite(self._runner, self.config.listen_host, self.config.listen_port) + await site.start() + LOG.info( + "Telegram Login demo listening at %s (issuer=%s client_id=%s)", + self.config.public_url, + self.config.issuer, + self.config.client_id, + ) + + async def close(self) -> None: + if self._runner is not None: + await self._runner.cleanup() + self._runner = None + if self._http is not None: + await self._http.close() + self._http = None + + async def _put_flow(self, flow: PendingFlow) -> str: + flow_id = secrets.token_urlsafe(24) + now = time.time() + async with self._flow_lock: + self._flows = { + key: value for key, value in self._flows.items() if value.expires_at > now + } + if len(self._flows) >= MAX_PENDING_FLOWS: + oldest = min(self._flows, key=lambda key: self._flows[key].expires_at) + del self._flows[oldest] + self._flows[flow_id] = flow + return flow_id + + async def _take_flow(self, flow_id: str, *, consume: bool) -> PendingFlow: + async with self._flow_lock: + flow = self._flows.get(flow_id) + if flow is None or flow.expires_at <= time.time(): + self._flows.pop(flow_id, None) + raise ValueError("login flow is invalid or expired") + if consume: + del self._flows[flow_id] + return flow + + @staticmethod + def _headers(response: web.StreamResponse) -> None: + response.headers["Cache-Control"] = "no-store" + response.headers["Pragma"] = "no-cache" + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["Referrer-Policy"] = "no-referrer" + + async def health(self, _: web.Request) -> web.Response: + response = web.json_response({"status": "ok"}) + self._headers(response) + return response + + async def root(self, request: web.Request) -> web.Response: + legacy_result = "" + if "hash" in request.query: + try: + signed_names = { + "auth_date", "first_name", "id", "last_name", "photo_url", "username", "hash" + } + if any(len(request.query.getall(key, [])) != 1 for key in signed_names if key in request.query): + raise ValueError("duplicate legacy login field") + query = {key: request.query[key] for key in request.query} + identity = verify_legacy_login_query(query, self.bot_token) + legacy_result = ( + "

login_url HMAC verified for user " + + html.escape(identity["id"]) + + ".

" + ) + except ValueError: + legacy_result = '

login_url HMAC verification failed.

' + + nonce = secrets.token_urlsafe(24) + flow_id = await self._put_flow(PendingFlow(nonce=nonce, expires_at=time.time() + FLOW_TTL_SECONDS)) + sdk_url = self.config.issuer + "/js/telegram-login.js" + csp_nonce = secrets.token_urlsafe(18) + code_link = '
Authorization Code + PKCE' + if not self.config.code_flow_enabled: + code_link = 'Code flow disabled: configure client secret.' + script_config = json.dumps( + {"clientID": self.config.client_id, "flowID": flow_id, "nonce": nonce}, + separators=(",", ":"), + ).replace("<", "\\u003c") + body = f""" +Bedolaga Telegram Login Demo + +

Bedolaga Telegram Login Demo

{legacy_result} +

This page verifies the self-hosted JavaScript SDK and the standard server-side OIDC flow.

+{code_link}
Ready.
+ +""" + response = web.Response(text=body, content_type="text/html") + self._headers(response) + response.headers["Content-Security-Policy"] = ( + "default-src 'none'; style-src 'unsafe-inline'; " + f"script-src '{csp_nonce}' {self.config.issuer}; connect-src 'self' {self.config.issuer}; " + "frame-ancestors 'none'; base-uri 'none'; form-action 'self'" + ) + # CSP nonce source expressions include the nonce- prefix. + response.headers["Content-Security-Policy"] = response.headers["Content-Security-Policy"].replace( + f"'{csp_nonce}'", f"'nonce-{csp_nonce}'" + ) + return response + + async def start_code_flow(self, _: web.Request) -> web.Response: + if not self.config.code_flow_enabled: + raise web.HTTPNotFound() + verifier, challenge = generate_pkce() + nonce = secrets.token_urlsafe(24) + state = await self._put_flow( + PendingFlow(nonce=nonce, code_verifier=verifier, expires_at=time.time() + FLOW_TTL_SECONDS) + ) + query = urlencode( + { + "client_id": self.config.client_id, + "redirect_uri": self.config.redirect_uri, + "response_type": "code", + "scope": "openid profile phone telegram:bot_access", + "state": state, + "nonce": nonce, + "code_challenge": challenge, + "code_challenge_method": "S256", + } + ) + response = web.HTTPFound(self.config.issuer + "/auth?" + query) + self._headers(response) + raise response + + async def code_callback(self, request: web.Request) -> web.Response: + state = request.query.get("state", "") + try: + flow = await self._take_flow(state, consume=True) + except ValueError: + return self._result_page("Authorization failed", {"error": "invalid_or_expired_state"}, ok=False) + if request.query.get("error"): + return self._result_page("Authorization declined", {"error": request.query["error"]}, ok=False) + code = request.query.get("code", "") + if not code or len(code) > 2048: + return self._result_page("Authorization failed", {"error": "missing_code"}, ok=False) + try: + token = await self._exchange_code(code, flow.code_verifier) + claims = await self.verify_id_token(token, flow.nonce) + except Exception as exc: # noqa: BLE001 - convert all protocol failures to a safe demo page + LOG.warning("OIDC code flow verification failed: %s", type(exc).__name__) + return self._result_page("Authorization failed", {"error": "token_verification_failed"}, ok=False) + return self._result_page("Authorization complete", _safe_claims(claims), ok=True) + + async def verify_popup(self, request: web.Request) -> web.Response: + origin = request.headers.get("Origin") + if origin and origin != self.config.origin: + return web.json_response({"error": "invalid_origin"}, status=403) + try: + payload = await request.json() + flow_id = str(payload.get("flow_id", "")) + id_token = str(payload.get("id_token", "")) + in_app = payload.get("in_app", False) + if ( + not flow_id + or len(flow_id) > 128 + or not id_token + or len(id_token) > 16384 + or not isinstance(in_app, bool) + ): + raise ValueError("invalid popup response") + flow = await self._take_flow(flow_id, consume=True) + # Telegram's official Mini App /inapp contract has exactly four + # request parameters and does not carry the JS API nonce. Popup + # flows still require it; Mini App tokens must omit it. + claims = await self.verify_id_token(id_token, "" if in_app else flow.nonce) + except Exception as exc: # noqa: BLE001 - safe public validation failure + LOG.info("OIDC popup verification rejected: %s", type(exc).__name__) + response = web.json_response({"error": "token_verification_failed"}, status=400) + self._headers(response) + return response + response = web.json_response({"claims": _safe_claims(claims)}) + self._headers(response) + return response + + async def _discovery(self) -> dict[str, Any]: + if self._http is None: + raise RuntimeError("login demo server is not started") + async with self._http.get(self.config.issuer + "/.well-known/openid-configuration") as response: + response.raise_for_status() + document = await response.json() + if document.get("issuer") != self.config.issuer: + raise ValueError("OIDC issuer mismatch") + issuer_origin = urlsplit(self.config.issuer).netloc + for name in ("token_endpoint", "jwks_uri"): + endpoint = urlsplit(str(document.get(name, ""))) + if endpoint.scheme != urlsplit(self.config.issuer).scheme or endpoint.netloc != issuer_origin: + raise ValueError(f"OIDC {name} must share the configured issuer origin") + return document + + async def _exchange_code(self, code: str, verifier: str) -> str: + if self._http is None: + raise RuntimeError("login demo server is not started") + discovery = await self._discovery() + form = { + "grant_type": "authorization_code", + "code": code, + "redirect_uri": self.config.redirect_uri, + "code_verifier": verifier, + } + async with self._http.post( + discovery["token_endpoint"], + data=form, + auth=BasicAuth(self.config.client_id, self.config.client_secret), + ) as response: + document = await response.json() + if response.status != 200: + raise ValueError("OIDC token endpoint rejected the grant") + token = document.get("id_token") + if not isinstance(token, str) or not token: + raise ValueError("OIDC token response omitted id_token") + return token + + async def verify_id_token(self, token: str, nonce: str) -> dict[str, Any]: + if self._http is None: + raise RuntimeError("login demo server is not started") + discovery = await self._discovery() + header = jwt.get_unverified_header(token) + kid, algorithm = header.get("kid"), header.get("alg") + if not isinstance(kid, str) or algorithm not in OIDC_ALGORITHMS: + raise ValueError("unsupported ID token header") + async with self._http.get(discovery["jwks_uri"]) as response: + response.raise_for_status() + document = await response.json() + raw_key = next((key for key in document.get("keys", []) if key.get("kid") == kid), None) + if raw_key is None: + raise ValueError("ID token signing key not found") + public_key = jwt.PyJWK.from_dict(raw_key, algorithm=algorithm).key + required_claims = ["iss", "aud", "sub", "iat", "exp"] + if nonce: + required_claims.append("nonce") + claims = jwt.decode( + token, + public_key, + algorithms=[algorithm], + audience=self.config.client_id, + issuer=self.config.issuer, + options={"require": required_claims}, + ) + if nonce and not hmac.compare_digest(str(claims.get("nonce", "")), nonce): + raise ValueError("ID token nonce mismatch") + if not nonce and claims.get("nonce") not in (None, ""): + raise ValueError("unexpected ID token nonce") + if str(claims.get("sub", "")) != str(claims.get("id", "")): + raise ValueError("ID token subject mismatch") + return claims + + def _result_page(self, title: str, payload: dict[str, Any], *, ok: bool) -> web.Response: + css_class = "ok" if ok else "error" + body = ( + '' + + html.escape(title) + + "

" + + html.escape(title) + + "

"
+            + html.escape(json.dumps(payload, indent=2, ensure_ascii=False))
+            + '

Run another flow

' + ) + response = web.Response(text=body, content_type="text/html") + self._headers(response) + response.headers["Content-Security-Policy"] = "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'; base-uri 'none'" + return response diff --git a/cmd/bots/bedolagaformat/requirements.txt b/cmd/bots/bedolagaformat/requirements.txt index 1b94ec4e..658a9e23 100644 --- a/cmd/bots/bedolagaformat/requirements.txt +++ b/cmd/bots/bedolagaformat/requirements.txt @@ -1 +1,2 @@ aiogram==3.30.0 +PyJWT[crypto]==2.10.1 diff --git a/cmd/bots/bedolagaformat/test_demo.py b/cmd/bots/bedolagaformat/test_demo.py index d4e005e2..00cd7123 100644 --- a/cmd/bots/bedolagaformat/test_demo.py +++ b/cmd/bots/bedolagaformat/test_demo.py @@ -11,6 +11,7 @@ from aiogram.types import InputRichMessage MODULE_PATH = Path(__file__).with_name("demo.py") +sys.path.insert(0, str(MODULE_PATH.parent)) SPEC = importlib.util.spec_from_file_location("bedolagaformat_demo", MODULE_PATH) assert SPEC is not None and SPEC.loader is not None demo = importlib.util.module_from_spec(SPEC) @@ -89,6 +90,44 @@ class BedolagaFormatDemoTest(unittest.IsolatedAsyncioTestCase): self.assertEqual(edit.kwargs["message_id"], 21) self.assertIn("EDITED", edit.kwargs["rich_message"].html) + def test_login_demo_keyboard_has_login_url_and_plain_oidc_link(self) -> None: + config = demo.LoginDemoConfig( + issuer="https://oauth.example", + client_id="9001", + client_secret="secret", + public_url="https://rp.example", + listen_host="127.0.0.1", + listen_port=3000, + ) + markup = demo.login_demo_keyboard(config) + login = markup.inline_keyboard[0][0].login_url + self.assertIsNotNone(login) + self.assertEqual(login.url, "https://rp.example/") + self.assertTrue(login.request_write_access) + self.assertEqual(markup.inline_keyboard[1][0].url, "https://rp.example/") + + async def test_send_login_demo_preserves_default_html_and_keyboard(self) -> None: + config = demo.LoginDemoConfig( + issuer="https://oauth.example", + client_id="9001", + client_secret="secret", + public_url="https://rp.example", + listen_host="127.0.0.1", + listen_port=3000, + ) + bot = AsyncMock() + bot.send_message.return_value = SentMessage(31) + + message_id = await demo.send_login_demo(bot, 1780243200, "BEDOLAGA123", config) + + self.assertEqual(message_id, 31) + call = bot.send_message.await_args + self.assertNotIn("parse_mode", call.kwargs) + self.assertEqual( + call.kwargs["reply_markup"].inline_keyboard[0][0].login_url.url, + "https://rp.example/", + ) + if __name__ == "__main__": unittest.main() diff --git a/cmd/bots/bedolagaformat/test_login_demo.py b/cmd/bots/bedolagaformat/test_login_demo.py new file mode 100644 index 00000000..7ba9bb54 --- /dev/null +++ b/cmd/bots/bedolagaformat/test_login_demo.py @@ -0,0 +1,154 @@ +import asyncio +import hashlib +import hmac +from pathlib import Path +import sys +import time +import unittest + +from aiohttp import ClientSession, web +from aiohttp.test_utils import TestServer +from cryptography.hazmat.primitives.asymmetric import rsa +import jwt + + +sys.path.insert(0, str(Path(__file__).parent)) +import login_demo as demo # noqa: E402 + + +class LoginDemoHelpersTest(unittest.TestCase): + def test_legacy_login_hmac_and_freshness(self) -> None: + now = 1_800_000_000 + token = "9001:bot-secret" + values = { + "auth_date": str(now - 10), + "first_name": "Alice", + "id": "42", + "username": "alice", + } + data_check = "\n".join(f"{key}={values[key]}" for key in sorted(values)) + key = hashlib.sha256(token.encode()).digest() + values["hash"] = hmac.new(key, data_check.encode(), hashlib.sha256).hexdigest() + values["untrusted_existing_query"] = "not-signed" + + verified = demo.verify_legacy_login_query(values, token, now=now) + + self.assertEqual(verified["id"], "42") + self.assertNotIn("untrusted_existing_query", verified) + with self.assertRaisesRegex(ValueError, "signature"): + demo.verify_legacy_login_query({**values, "id": "43"}, token, now=now) + with self.assertRaisesRegex(ValueError, "expired"): + demo.verify_legacy_login_query(values, token, now=now + 3600) + + def test_web_origins_and_listen_are_strict(self) -> None: + self.assertEqual( + demo.normalize_web_base("https://rp.example/", name="RP"), + "https://rp.example", + ) + self.assertEqual( + demo.normalize_web_base("http://127.0.0.1:3000", name="RP"), + "http://127.0.0.1:3000", + ) + with self.assertRaises(ValueError): + demo.normalize_web_base("http://rp.example", name="RP") + with self.assertRaises(ValueError): + demo.normalize_web_base("https://rp.example/callback", name="RP") + self.assertEqual(demo.parse_listen("127.0.0.1:3000"), ("127.0.0.1", 3000)) + + +class LoginDemoTokenTest(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self) -> None: + self.private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + raw_jwk = jwt.algorithms.RSAAlgorithm.to_jwk(self.private_key.public_key(), as_dict=True) + raw_jwk.update({"kid": "demo-rs256", "alg": "RS256", "use": "sig"}) + self.jwk = raw_jwk + self.issuer = "" + + async def discovery(_: web.Request) -> web.Response: + return web.json_response( + { + "issuer": self.issuer, + "token_endpoint": self.issuer + "/token", + "jwks_uri": self.issuer + "/jwks", + } + ) + + async def jwks(_: web.Request) -> web.Response: + return web.json_response({"keys": [self.jwk]}) + + app = web.Application() + app.add_routes([web.get("/.well-known/openid-configuration", discovery), web.get("/jwks", jwks)]) + self.http_server = TestServer(app) + await self.http_server.start_server() + self.issuer = str(self.http_server.make_url("")).rstrip("/") + config = demo.LoginDemoConfig( + issuer=self.issuer, + client_id="9001", + client_secret="secret", + public_url="http://127.0.0.1:3000", + listen_host="127.0.0.1", + listen_port=3000, + ) + self.demo = demo.LoginDemoServer(config, "9001:bot-secret") + self.demo._http = ClientSession() + + async def asyncTearDown(self) -> None: + await self.demo._http.close() + await self.http_server.close() + + async def test_id_token_requires_signature_issuer_audience_nonce_and_subject(self) -> None: + now = int(time.time()) + claims = { + "iss": self.issuer, + "aud": "9001", + "sub": "42", + "id": 42, + "iat": now, + "exp": now + 300, + "nonce": "expected-nonce", + "name": "Alice", + } + token = jwt.encode( + claims, + self.private_key, + algorithm="RS256", + headers={"kid": "demo-rs256"}, + ) + + verified = await self.demo.verify_id_token(token, "expected-nonce") + + self.assertEqual(verified["sub"], "42") + with self.assertRaisesRegex(ValueError, "nonce"): + await self.demo.verify_id_token(token, "wrong-nonce") + with self.assertRaisesRegex(ValueError, "nonce"): + await self.demo.verify_id_token(token, "") + + in_app_claims = dict(claims) + in_app_claims.pop("nonce") + in_app_token = jwt.encode( + in_app_claims, + self.private_key, + algorithm="RS256", + headers={"kid": "demo-rs256"}, + ) + verified_in_app = await self.demo.verify_id_token(in_app_token, "") + self.assertEqual(verified_in_app["sub"], "42") + + async def test_pending_flow_is_one_time_and_expiring(self) -> None: + flow_id = await self.demo._put_flow( + demo.PendingFlow(nonce="n", expires_at=time.time() + 10) + ) + flow = await self.demo._take_flow(flow_id, consume=True) + self.assertEqual(flow.nonce, "n") + with self.assertRaises(ValueError): + await self.demo._take_flow(flow_id, consume=True) + + expired = await self.demo._put_flow( + demo.PendingFlow(nonce="old", expires_at=time.time() - 1) + ) + with self.assertRaises(ValueError): + await self.demo._take_flow(expired, consume=True) + + +if __name__ == "__main__": + unittest.main() diff --git a/cmd/telegramloginkeygen/main.go b/cmd/telegramloginkeygen/main.go new file mode 100644 index 00000000..93745096 --- /dev/null +++ b/cmd/telegramloginkeygen/main.go @@ -0,0 +1,391 @@ +// Command telegramloginkeygen initializes and rotates telesrv Telegram Login +// key files without ever writing secret material to stdout. +package main + +import ( + "crypto/ecdsa" + "crypto/ed25519" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "errors" + "flag" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" + + telegramlogin "telesrv/internal/app/telegramlogin" + "telesrv/internal/domain" +) + +const maxManifestBytes = 1 << 20 +const signingRetirementMargin = 10 * time.Minute + +type signingManifest struct { + Version int `json:"version"` + Keys []signingManifestKey `json:"keys"` +} + +type signingManifestKey struct { + Algorithm domain.TelegramLoginSigningAlgorithm `json:"algorithm"` + KeyID string `json:"kid"` + PrivateKeyFile string `json:"private_key_file"` + Active bool `json:"active"` + PublishUntil string `json:"publish_until,omitempty"` +} + +type codeManifest struct { + Version int `json:"version"` + Active string `json:"active"` + Keys map[string]string `json:"keys"` +} + +type options struct { + mode string + dir string + algorithm domain.TelegramLoginSigningAlgorithm + publishFor time.Duration + idTokenTTL time.Duration + now func() time.Time +} + +func main() { + mode := flag.String("mode", "init", "init, rotate-signing, or rotate-code") + dir := flag.String("dir", "data/telegram-login", "key directory") + algorithm := flag.String("algorithm", "RS256", "signing algorithm to rotate: RS256, ES256, or EdDSA") + publishFor := flag.Duration("publish-for", 2*time.Hour, "how long the retiring public key remains in JWKS") + idTokenTTL := flag.Duration("id-token-ttl", time.Hour, "configured TELESRV_TELEGRAM_LOGIN_ID_TOKEN_TTL") + flag.Parse() + if flag.NArg() != 0 { + fatal(errors.New("positional arguments are not accepted")) + } + opts := options{ + mode: strings.ToLower(strings.TrimSpace(*mode)), dir: strings.TrimSpace(*dir), + algorithm: domain.TelegramLoginSigningAlgorithm(strings.ToUpper(strings.TrimSpace(*algorithm))), + publishFor: *publishFor, idTokenTTL: *idTokenTTL, now: time.Now, + } + if err := run(opts); err != nil { + fatal(err) + } + fmt.Printf("Telegram Login key operation %s completed in %s; restart all instances to load one consistent key ring.\n", opts.mode, opts.dir) +} + +func fatal(err error) { + fmt.Fprintln(os.Stderr, "telegramloginkeygen:", err) + os.Exit(1) +} + +func run(opts options) error { + if opts.now == nil { + opts.now = time.Now + } + if opts.dir == "" { + return errors.New("key directory is required") + } + absDir, err := filepath.Abs(opts.dir) + if err != nil { + return fmt.Errorf("resolve key directory: %w", err) + } + if err := os.MkdirAll(absDir, 0o700); err != nil { + return fmt.Errorf("create key directory: %w", err) + } + if err := os.Chmod(absDir, 0o700); err != nil { + return fmt.Errorf("restrict key directory: %w", err) + } + lockPath := filepath.Join(absDir, ".keygen.lock") + lock, err := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) + if err != nil { + return fmt.Errorf("acquire key operation lock: %w", err) + } + _ = lock.Close() + defer func() { _ = os.Remove(lockPath) }() + + switch opts.mode { + case "init": + return initialize(absDir, opts.now().UTC()) + case "rotate-signing": + if opts.idTokenTTL < time.Minute || opts.idTokenTTL > 24*time.Hour { + return errors.New("id-token-ttl must match the configured 1m..24h ID-token TTL") + } + if opts.publishFor < opts.idTokenTTL+signingRetirementMargin || opts.publishFor > 90*24*time.Hour { + return fmt.Errorf("publish-for must be at least id-token-ttl plus %s and at most 2160h", signingRetirementMargin) + } + return rotateSigning(absDir, opts.algorithm, opts.publishFor, opts.now().UTC()) + case "rotate-code": + return rotateCode(absDir, opts.now().UTC()) + default: + return errors.New("mode must be init, rotate-signing, or rotate-code") + } +} + +func initialize(dir string, now time.Time) error { + for _, name := range []string{"signing-keys.json", "code-keys.json", "client-secret-pepper"} { + if _, err := os.Lstat(filepath.Join(dir, name)); err == nil { + return fmt.Errorf("refusing to overwrite existing %s", name) + } else if !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("inspect %s: %w", name, err) + } + } + + manifest := signingManifest{Version: 1} + for _, algorithm := range []domain.TelegramLoginSigningAlgorithm{ + domain.TelegramLoginSigningRS256, + domain.TelegramLoginSigningES256, + domain.TelegramLoginSigningEdDSA, + } { + entry, err := generateSigningKey(dir, algorithm, now) + if err != nil { + return err + } + manifest.Keys = append(manifest.Keys, entry) + } + if err := writeSigningManifest(dir, manifest); err != nil { + return err + } + codeID, err := newKeyID("code", now) + if err != nil { + return err + } + codeKey, err := randomBytes(32) + if err != nil { + return err + } + if err := writeCodeManifest(dir, codeManifest{ + Version: 1, Active: codeID, + Keys: map[string]string{codeID: base64.RawURLEncoding.EncodeToString(codeKey)}, + }); err != nil { + return err + } + pepper, err := randomBytes(32) + if err != nil { + return err + } + if err := writeExclusive(filepath.Join(dir, "client-secret-pepper"), []byte(base64.RawURLEncoding.EncodeToString(pepper)+"\n")); err != nil { + return fmt.Errorf("write client-secret pepper: %w", err) + } + _, err = telegramlogin.LoadClientSecretPepper(filepath.Join(dir, "client-secret-pepper")) + return err +} + +func rotateSigning(dir string, algorithm domain.TelegramLoginSigningAlgorithm, publishFor time.Duration, now time.Time) error { + if algorithm != domain.TelegramLoginSigningRS256 && algorithm != domain.TelegramLoginSigningES256 && algorithm != domain.TelegramLoginSigningEdDSA { + return errors.New("default keygen supports RS256, ES256, and EdDSA; ES256K requires an explicit jwx_es256k build and external JWK lifecycle") + } + path := filepath.Join(dir, "signing-keys.json") + var manifest signingManifest + if err := readStrictJSON(path, &manifest); err != nil { + return fmt.Errorf("read signing manifest: %w", err) + } + if manifest.Version != 1 || len(manifest.Keys) == 0 || len(manifest.Keys) >= 32 { + return errors.New("signing manifest version or key count is invalid") + } + foundActive := false + kept := make([]signingManifestKey, 0, len(manifest.Keys)+1) + for _, key := range manifest.Keys { + if !key.Active && key.PublishUntil != "" { + until, err := time.Parse(time.RFC3339, key.PublishUntil) + if err != nil { + return fmt.Errorf("parse retiring key %s: %w", key.KeyID, err) + } + if !now.Before(until) { + continue + } + } + if key.Algorithm == algorithm && key.Active { + if foundActive { + return fmt.Errorf("multiple active %s keys", algorithm) + } + foundActive = true + key.Active = false + key.PublishUntil = now.Add(publishFor).UTC().Format(time.RFC3339) + } + kept = append(kept, key) + } + if !foundActive { + return fmt.Errorf("no active %s key to rotate", algorithm) + } + entry, err := generateSigningKey(dir, algorithm, now) + if err != nil { + return err + } + manifest.Keys = append(kept, entry) + return writeSigningManifest(dir, manifest) +} + +func rotateCode(dir string, now time.Time) error { + path := filepath.Join(dir, "code-keys.json") + var manifest codeManifest + if err := readStrictJSON(path, &manifest); err != nil { + return fmt.Errorf("read code-key manifest: %w", err) + } + if manifest.Version != 1 || manifest.Active == "" || len(manifest.Keys) == 0 || len(manifest.Keys) >= 16 { + return errors.New("code-key manifest is invalid or at its 16-key safety limit") + } + keyID, err := newKeyID("code", now) + if err != nil { + return err + } + key, err := randomBytes(32) + if err != nil { + return err + } + manifest.Active = keyID + manifest.Keys[keyID] = base64.RawURLEncoding.EncodeToString(key) + return writeCodeManifest(dir, manifest) +} + +func generateSigningKey(dir string, algorithm domain.TelegramLoginSigningAlgorithm, now time.Time) (signingManifestKey, error) { + keyID, err := newKeyID(strings.ToLower(string(algorithm)), now) + if err != nil { + return signingManifestKey{}, err + } + var privateKey any + switch algorithm { + case domain.TelegramLoginSigningRS256: + privateKey, err = rsa.GenerateKey(rand.Reader, 3072) + case domain.TelegramLoginSigningES256: + privateKey, err = ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + case domain.TelegramLoginSigningEdDSA: + _, privateKey, err = ed25519.GenerateKey(rand.Reader) + default: + return signingManifestKey{}, fmt.Errorf("unsupported keygen algorithm %s", algorithm) + } + if err != nil { + return signingManifestKey{}, fmt.Errorf("generate %s key: %w", algorithm, err) + } + der, err := x509.MarshalPKCS8PrivateKey(privateKey) + if err != nil { + return signingManifestKey{}, fmt.Errorf("marshal %s key: %w", algorithm, err) + } + filename := "signing-" + keyID + ".pem" + pemBytes := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}) + if err := writeExclusive(filepath.Join(dir, filename), pemBytes); err != nil { + return signingManifestKey{}, fmt.Errorf("write %s key: %w", algorithm, err) + } + return signingManifestKey{ + Algorithm: algorithm, KeyID: keyID, PrivateKeyFile: filename, Active: true, + }, nil +} + +func newKeyID(prefix string, now time.Time) (string, error) { + raw, err := randomBytes(8) + if err != nil { + return "", err + } + return fmt.Sprintf("%s-%s-%s", prefix, now.UTC().Format("20060102T150405Z"), base64.RawURLEncoding.EncodeToString(raw)), nil +} + +func randomBytes(size int) ([]byte, error) { + raw := make([]byte, size) + if _, err := rand.Read(raw); err != nil { + return nil, fmt.Errorf("read cryptographic randomness: %w", err) + } + return raw, nil +} + +func writeSigningManifest(dir string, manifest signingManifest) error { + return writeValidatedManifest(filepath.Join(dir, "signing-keys.json"), manifest, func(path string) error { + _, err := telegramlogin.LoadSigningKeyRing(path, time.Now) + return err + }) +} + +func writeCodeManifest(dir string, manifest codeManifest) error { + return writeValidatedManifest(filepath.Join(dir, "code-keys.json"), manifest, func(path string) error { + _, err := telegramlogin.LoadCodeSealer(path) + return err + }) +} + +func writeValidatedManifest(path string, value any, validate func(string) error) error { + data, err := json.MarshalIndent(value, "", " ") + if err != nil { + return fmt.Errorf("marshal manifest: %w", err) + } + data = append(data, '\n') + temp, err := os.CreateTemp(filepath.Dir(path), ".telegram-login-manifest-*") + if err != nil { + return fmt.Errorf("create temporary manifest: %w", err) + } + tempPath := temp.Name() + defer func() { _ = os.Remove(tempPath) }() + if err := temp.Chmod(0o600); err != nil { + _ = temp.Close() + return err + } + if _, err := temp.Write(data); err != nil { + _ = temp.Close() + return err + } + if err := temp.Sync(); err != nil { + _ = temp.Close() + return err + } + if err := temp.Close(); err != nil { + return err + } + if err := validate(tempPath); err != nil { + return fmt.Errorf("validate generated manifest: %w", err) + } + if err := os.Rename(tempPath, path); err != nil { + return fmt.Errorf("atomically replace manifest: %w", err) + } + return nil +} + +func writeExclusive(path string, data []byte) error { + file, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) + if err != nil { + return err + } + ok := false + defer func() { + _ = file.Close() + if !ok { + _ = os.Remove(path) + } + }() + if _, err := file.Write(data); err != nil { + return err + } + if err := file.Sync(); err != nil { + return err + } + ok = true + return nil +} + +func readStrictJSON(path string, target any) error { + file, err := os.Open(path) + if err != nil { + return err + } + defer func() { _ = file.Close() }() + info, err := file.Stat() + if err != nil { + return err + } + if !info.Mode().IsRegular() || info.Size() > maxManifestBytes { + return errors.New("manifest must be a bounded regular file") + } + decoder := json.NewDecoder(io.LimitReader(file, maxManifestBytes+1)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(target); err != nil { + return err + } + var extra any + if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) { + if err == nil { + return errors.New("manifest contains multiple JSON values") + } + return err + } + return nil +} diff --git a/cmd/telegramloginkeygen/main_test.go b/cmd/telegramloginkeygen/main_test.go new file mode 100644 index 00000000..3cf8dcc0 --- /dev/null +++ b/cmd/telegramloginkeygen/main_test.go @@ -0,0 +1,109 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + "time" + + telegramlogin "telesrv/internal/app/telegramlogin" + "telesrv/internal/domain" +) + +func TestInitializeAndRotateKeyFiles(t *testing.T) { + dir := t.TempDir() + now := time.Date(2026, 7, 21, 1, 2, 3, 0, time.UTC) + if err := run(options{mode: "init", dir: dir, now: func() time.Time { return now }}); err != nil { + t.Fatal(err) + } + ring, err := telegramlogin.LoadSigningKeyRing(filepath.Join(dir, "signing-keys.json"), func() time.Time { return now }) + if err != nil { + t.Fatal(err) + } + if got := ring.SupportedAlgorithms(); len(got) != 3 || got[0] != "RS256" || got[1] != "ES256" || got[2] != "EdDSA" { + t.Fatalf("supported algorithms = %#v", got) + } + if _, err := telegramlogin.LoadCodeSealer(filepath.Join(dir, "code-keys.json")); err != nil { + t.Fatal(err) + } + if _, err := telegramlogin.LoadClientSecretPepper(filepath.Join(dir, "client-secret-pepper")); err != nil { + t.Fatal(err) + } + if err := run(options{mode: "init", dir: dir, now: func() time.Time { return now }}); err == nil { + t.Fatal("second initialization unexpectedly overwrote keys") + } + + later := now.Add(time.Minute) + if err := run(options{ + mode: "rotate-signing", dir: dir, algorithm: domain.TelegramLoginSigningRS256, + publishFor: 2 * time.Hour, idTokenTTL: time.Hour, now: func() time.Time { return later }, + }); err != nil { + t.Fatal(err) + } + var manifest signingManifest + readJSONForTest(t, filepath.Join(dir, "signing-keys.json"), &manifest) + active, retiring := 0, 0 + for _, key := range manifest.Keys { + if key.Algorithm != domain.TelegramLoginSigningRS256 { + continue + } + if key.Active { + active++ + } else if key.PublishUntil == later.Add(2*time.Hour).Format(time.RFC3339) { + retiring++ + } + } + if active != 1 || retiring != 1 { + t.Fatalf("RS256 active=%d retiring=%d manifest=%#v", active, retiring, manifest) + } + ring, err = telegramlogin.LoadSigningKeyRing(filepath.Join(dir, "signing-keys.json"), func() time.Time { return later }) + if err != nil { + t.Fatal(err) + } + jwks, _, err := ring.JWKS() + if err != nil { + t.Fatal(err) + } + var set struct { + Keys []json.RawMessage `json:"keys"` + } + if err := json.Unmarshal(jwks, &set); err != nil || len(set.Keys) != 4 { + t.Fatalf("JWKS key count=%d err=%v body=%s", len(set.Keys), err, jwks) + } + + var before codeManifest + readJSONForTest(t, filepath.Join(dir, "code-keys.json"), &before) + if err := run(options{mode: "rotate-code", dir: dir, now: func() time.Time { return later }}); err != nil { + t.Fatal(err) + } + var after codeManifest + readJSONForTest(t, filepath.Join(dir, "code-keys.json"), &after) + if after.Active == before.Active || len(after.Keys) != 2 { + t.Fatalf("code ring before=%#v after=%#v", before, after) + } + if _, err := telegramlogin.LoadCodeSealer(filepath.Join(dir, "code-keys.json")); err != nil { + t.Fatal(err) + } +} + +func TestRotateSigningRejectsTooShortRetirementWindow(t *testing.T) { + err := run(options{ + mode: "rotate-signing", dir: t.TempDir(), algorithm: domain.TelegramLoginSigningRS256, + publishFor: 69 * time.Minute, idTokenTTL: time.Hour, now: time.Now, + }) + if err == nil { + t.Fatal("short publish-for unexpectedly accepted") + } +} + +func readJSONForTest(t *testing.T, path string, target any) { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(data, target); err != nil { + t.Fatal(err) + } +} diff --git a/cmd/telesrv/main.go b/cmd/telesrv/main.go index 6df6a58a..b078525d 100644 --- a/cmd/telesrv/main.go +++ b/cmd/telesrv/main.go @@ -49,6 +49,7 @@ import ( "telesrv/internal/app/stargifts" "telesrv/internal/app/stars" storiesapp "telesrv/internal/app/stories" + telegramloginapp "telesrv/internal/app/telegramlogin" themesapp "telesrv/internal/app/themes" translationapp "telesrv/internal/app/translation" "telesrv/internal/app/updates" @@ -69,6 +70,7 @@ import ( "telesrv/internal/store/memory" "telesrv/internal/store/postgres" "telesrv/internal/store/redisstore" + "telesrv/internal/telegramloginhttp" "telesrv/internal/turnsrv" "telesrv/internal/web" ) @@ -346,12 +348,60 @@ func run(logger *zap.Logger) error { } defer pool.Close() + var telegramLoginService *telegramloginapp.Service + var telegramLoginIDTokens *telegramloginapp.IDTokenIssuer + var telegramLoginHTTPHandler http.Handler + if cfg.TelegramLoginEnabled { + codeSealer, err := telegramloginapp.LoadCodeSealer(cfg.TelegramLoginCodeKeysFile) + if err != nil { + return fmt.Errorf("load telegram login code keys: %w", err) + } + clientSecretPepper, err := telegramloginapp.LoadClientSecretPepper(cfg.TelegramLoginSecretPepperFile) + if err != nil { + return fmt.Errorf("load telegram login client-secret pepper: %w", err) + } + signingKeys, err := telegramloginapp.LoadSigningKeyRing(cfg.TelegramLoginSigningKeysFile, time.Now) + if err != nil { + return fmt.Errorf("load telegram login signing keys: %w", err) + } + telegramLoginService, err = telegramloginapp.NewService(postgres.NewTelegramLoginStore(pool), codeSealer, telegramloginapp.Config{ + Issuer: cfg.TelegramLoginIssuer, AppScheme: cfg.PublicAppScheme, + AllowLoopbackHTTP: cfg.TelegramLoginAllowLoopbackHTTP, + ClientSecretPepper: clientSecretPepper, + SupportedSigningAlgorithms: signingKeys.ActiveAlgorithms(), + RequestTTL: cfg.TelegramLoginRequestTTL, CodeTTL: cfg.TelegramLoginCodeTTL, + }) + if err != nil { + return fmt.Errorf("initialize telegram login service: %w", err) + } + telegramLoginIDTokens, err = telegramloginapp.NewIDTokenIssuer(signingKeys, telegramloginapp.IDTokenIssuerConfig{ + Issuer: cfg.TelegramLoginIssuer, TTL: cfg.TelegramLoginIDTokenTTL, + }) + if err != nil { + return fmt.Errorf("initialize telegram login ID-token issuer: %w", err) + } + } + rdb, err := redisstore.Open(ctx, cfg.RedisAddr, cfg.RedisPassword, cfg.RedisDB) if err != nil { return fmt.Errorf("connect redis: %w", err) } defer func() { _ = rdb.Close() }() logger.Info("持久化依赖就绪", zap.String("redis", cfg.RedisAddr)) + if cfg.TelegramLoginEnabled { + telegramLoginHTTPHandler, err = telegramloginhttp.NewHandler(telegramloginhttp.Config{ + Service: telegramLoginService, Tokens: telegramLoginIDTokens, + Limiter: redisstore.NewRateLimiter(rdb), AppName: cfg.PublicAppName, + Logger: logger.Named("telegram-login-http"), TrustedProxyCIDRs: cfg.TelegramLoginTrustedProxyCIDRs, + AllowLoopbackHTTP: cfg.TelegramLoginAllowLoopbackHTTP, + }) + if err != nil { + return fmt.Errorf("initialize telegram login HTTP provider: %w", err) + } + logger.Info("Telegram Login/OIDC provider enabled", + zap.String("issuer", telegramLoginIDTokens.Issuer()), + zap.Strings("signing_algorithms", telegramLoginIDTokens.SupportedAlgorithms())) + } authKeyStore := postgres.NewAuthKeyStore(pool) userStore := postgres.NewUserStore(pool) @@ -585,6 +635,7 @@ func run(logger *zap.Logger) error { botsapp.WithUserCache(userCache), botsapp.WithStickerSetCreator(filesService), botsapp.WithUserStickerSets(accountService), + botsapp.WithTelegramLogin(telegramLoginService), botsapp.WithPublicBaseURL(cfg.PublicBaseURL)) groupCallStore := postgres.NewGroupCallStore(pool) groupCallsService := groupcallsapp.NewService(groupCallStore, groupcallsapp.WithPublicBaseURL(cfg.PublicBaseURL)) @@ -800,6 +851,7 @@ func run(logger *zap.Logger) error { EphemeralPush: ephemeralStore, EphemeralReports: ephemeralReportStore, Users: usersService, + TelegramLogin: telegramLoginService, Updates: updatesService, BootstrapUpdates: bootstrapUpdateStore, BotAPIUpdates: botAPIUpdateStore, @@ -886,6 +938,9 @@ func run(logger *zap.Logger) error { go activeSessions.RunPendingSweeper(ctx, time.Minute) go router.RunPremiumSweeper(ctx, cfg.PremiumSweepInterval, cfg.PremiumSweepBatch) go router.RunAccountLifecycle(ctx, time.Minute, 500) + if telegramLoginService != nil { + go runTelegramLoginRetention(ctx, telegramLoginService, cfg.TelegramLoginRetention, cfg.TelegramLoginSweepInterval, cfg.TelegramLoginSweepBatch, logger.Named("telegram-login-retention")) + } go func() { interval := cfg.StarGiftSweepInterval if interval <= 0 { @@ -934,6 +989,7 @@ func run(logger *zap.Logger) error { Photos: filesService, UniqueGifts: giftsService, GiftWithdrawals: giftsService, + TelegramLogin: telegramLoginHTTPHandler, }, logger.Named("public-web")); err != nil { return fmt.Errorf("start public Web: %w", err) } @@ -984,3 +1040,38 @@ func run(logger *zap.Logger) error { // public listener so no seed/prewarm work can run after port 2398 is exposed. return srv.ListenAndServe(ctx, cfg.ListenAddr) } + +func runTelegramLoginRetention(ctx context.Context, service *telegramloginapp.Service, retention, interval time.Duration, batch int, logger *zap.Logger) { + run := func() { + var total int64 + // Bound one tick even when a deployment accumulated years of stale data; + // subsequent ticks continue without monopolizing the database pool. + for range 10 { + deleted, err := service.DeleteExpiredArtifacts(ctx, time.Now().UTC().Add(-retention), batch) + if err != nil { + if ctx.Err() == nil { + logger.Warn("telegram_login_retention_failed", zap.Error(err)) + } + return + } + total += deleted + if deleted < int64(batch) { + break + } + } + if total > 0 { + logger.Info("telegram_login_retention_completed", zap.Int64("deleted", total)) + } + } + run() + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + run() + } + } +} diff --git a/deploy/migrations/0125_telegram_login_oidc.down.sql b/deploy/migrations/0125_telegram_login_oidc.down.sql new file mode 100644 index 00000000..4e01d998 --- /dev/null +++ b/deploy/migrations/0125_telegram_login_oidc.down.sql @@ -0,0 +1,14 @@ +DROP TABLE IF EXISTS public.telegram_login_codes; +DROP TABLE IF EXISTS public.web_authorizations; +DROP TABLE IF EXISTS public.telegram_login_requests; +DROP TABLE IF EXISTS public.bot_login_native_apps; +DROP TABLE IF EXISTS public.bot_login_allowed_urls; +DROP TABLE IF EXISTS public.bot_login_clients; +UPDATE public.bots +SET commands = COALESCE(( + SELECT jsonb_agg(command ORDER BY ordinal) + FROM jsonb_array_elements(commands) WITH ORDINALITY AS item(command, ordinal) + WHERE command->>'command' NOT IN ('setlogin','logininfo','resetloginsecret') + ), '[]'::jsonb), + updated_at = now() +WHERE bot_user_id = 93372553; diff --git a/deploy/migrations/0125_telegram_login_oidc.up.sql b/deploy/migrations/0125_telegram_login_oidc.up.sql new file mode 100644 index 00000000..6f389be2 --- /dev/null +++ b/deploy/migrations/0125_telegram_login_oidc.up.sql @@ -0,0 +1,253 @@ +-- Telegram Login / OIDC is one durable authorization aggregate shared by the +-- public HTTP provider and MTProto URL-auth RPCs. PostgreSQL is authoritative; +-- Redis/NOTIFY may wake waiters but may not own any transition below. +UPDATE public.bots +SET commands = commands || '[ + {"command":"setlogin","description":"configure Telegram Login"}, + {"command":"logininfo","description":"show Telegram Login configuration"}, + {"command":"resetloginsecret","description":"rotate an OIDC Client Secret"} +]'::jsonb, + updated_at = now() +WHERE bot_user_id = 93372553; + +CREATE TABLE public.bot_login_clients ( + bot_user_id bigint PRIMARY KEY REFERENCES public.bots(bot_user_id) ON DELETE CASCADE, + client_id text NOT NULL UNIQUE, + client_secret_hash bytea NOT NULL, + secret_version bigint DEFAULT 1 NOT NULL, + signing_algorithm text DEFAULT 'RS256'::text NOT NULL, + enabled boolean DEFAULT true NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT bot_login_clients_client_id_check + CHECK (client_id = bot_user_id::text AND length(client_id) BETWEEN 1 AND 64), + CONSTRAINT bot_login_clients_secret_hash_check CHECK (octet_length(client_secret_hash) = 32), + CONSTRAINT bot_login_clients_secret_version_check CHECK (secret_version > 0), + CONSTRAINT bot_login_clients_signing_algorithm_check + CHECK (signing_algorithm IN ('RS256','ES256','EdDSA','ES256K')) +); + +CREATE TABLE public.bot_login_allowed_urls ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + bot_user_id bigint NOT NULL REFERENCES public.bot_login_clients(bot_user_id) ON DELETE CASCADE, + kind text NOT NULL, + normalized_url text NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT bot_login_allowed_urls_kind_check CHECK (kind IN ('web_origin','redirect_uri')), + CONSTRAINT bot_login_allowed_urls_value_check CHECK (length(normalized_url) BETWEEN 1 AND 4096), + UNIQUE (bot_user_id, kind, normalized_url) +); + +CREATE INDEX bot_login_allowed_urls_bot_page_idx + ON public.bot_login_allowed_urls(bot_user_id, kind, id); + +CREATE TABLE public.bot_login_native_apps ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + bot_user_id bigint NOT NULL REFERENCES public.bot_login_clients(bot_user_id) ON DELETE CASCADE, + platform text NOT NULL, + application_id text NOT NULL, + verification_id text NOT NULL, + callback_uri text NOT NULL, + verified_display_name text NOT NULL, + enabled boolean DEFAULT true NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT bot_login_native_apps_platform_check CHECK (platform IN ('ios','android')), + CONSTRAINT bot_login_native_apps_app_id_check + CHECK (length(application_id) BETWEEN 3 AND 255 AND application_id ~ '^[A-Za-z0-9][A-Za-z0-9._-]*$'), + CONSTRAINT bot_login_native_apps_verification_check CHECK ( + (platform = 'ios' AND verification_id ~ '^[A-Z0-9]{10}$') + OR (platform = 'android' AND verification_id ~ '^[0-9A-F]{64}$') + ), + CONSTRAINT bot_login_native_apps_callback_check CHECK (length(callback_uri) BETWEEN 1 AND 4096), + CONSTRAINT bot_login_native_apps_name_check CHECK (length(btrim(verified_display_name)) BETWEEN 1 AND 128), + UNIQUE (bot_user_id, platform, application_id, verification_id), + UNIQUE (bot_user_id, callback_uri) +); + +CREATE INDEX bot_login_native_apps_bot_page_idx + ON public.bot_login_native_apps(bot_user_id, id); +CREATE INDEX bot_login_native_apps_callback_idx + ON public.bot_login_native_apps(bot_user_id, callback_uri) WHERE enabled; + +CREATE TABLE public.telegram_login_requests ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + request_token_hash bytea NOT NULL UNIQUE, + browser_token_hash bytea NOT NULL UNIQUE, + bot_user_id bigint NOT NULL REFERENCES public.bot_login_clients(bot_user_id) ON DELETE CASCADE, + client_id text NOT NULL, + signing_algorithm text NOT NULL, + source text NOT NULL, + response_type text NOT NULL, + redirect_uri text NOT NULL, + origin text DEFAULT ''::text NOT NULL, + domain text NOT NULL, + requested_scopes text[] NOT NULL, + oauth_state text DEFAULT ''::text NOT NULL, + nonce text DEFAULT ''::text NOT NULL, + code_challenge text NOT NULL, + code_challenge_method text NOT NULL, + browser text NOT NULL, + platform text NOT NULL, + ip text NOT NULL, + region text NOT NULL, + in_app_origin text DEFAULT ''::text NOT NULL, + is_app boolean DEFAULT false NOT NULL, + verified_app_name text DEFAULT ''::text NOT NULL, + match_codes text[] DEFAULT '{}'::text[] NOT NULL, + match_code text DEFAULT ''::text NOT NULL, + match_codes_first boolean DEFAULT false NOT NULL, + user_id_hint bigint DEFAULT 0 NOT NULL, + peer_type text DEFAULT ''::text NOT NULL, + peer_id bigint DEFAULT 0 NOT NULL, + message_id integer DEFAULT 0 NOT NULL, + button_id integer DEFAULT 0 NOT NULL, + status text DEFAULT 'pending'::text NOT NULL, + authorized_user_id bigint REFERENCES public.users(id), + profile_name text DEFAULT ''::text NOT NULL, + given_name text DEFAULT ''::text NOT NULL, + family_name text DEFAULT ''::text NOT NULL, + preferred_username text DEFAULT ''::text NOT NULL, + picture text DEFAULT ''::text NOT NULL, + phone_number text DEFAULT ''::text NOT NULL, + write_allowed boolean DEFAULT false NOT NULL, + phone_shared boolean DEFAULT false NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + expires_at timestamp with time zone NOT NULL, + approved_at timestamp with time zone, + declined_at timestamp with time zone, + CONSTRAINT telegram_login_requests_hashes_check + CHECK (octet_length(request_token_hash) = 32 AND octet_length(browser_token_hash) = 32), + CONSTRAINT telegram_login_requests_signing_algorithm_check + CHECK (signing_algorithm IN ('RS256','ES256','EdDSA','ES256K')), + CONSTRAINT telegram_login_requests_source_check + CHECK (source IN ('web','javascript','native','mini_app','message_button')), + CONSTRAINT telegram_login_requests_response_type_check CHECK (response_type IN ('code','post_message','legacy_url')), + CONSTRAINT telegram_login_requests_source_response_check CHECK ( + (source = 'web' AND response_type = 'code') + OR (source = 'javascript' AND response_type = 'post_message') + OR (source = 'native' AND response_type = 'code') + OR (source = 'mini_app' AND response_type = 'post_message') + OR (source = 'message_button' AND response_type = 'legacy_url') + ), + CONSTRAINT telegram_login_requests_url_check + CHECK (length(redirect_uri) BETWEEN 1 AND 4096 AND length(origin) <= 4096 + AND length(domain) BETWEEN 1 AND 255 AND length(in_app_origin) <= 4096), + CONSTRAINT telegram_login_requests_scope_check + CHECK (cardinality(requested_scopes) BETWEEN 1 AND 4 AND requested_scopes @> ARRAY['openid']::text[]), + CONSTRAINT telegram_login_requests_oauth_value_check + CHECK (length(oauth_state) <= 2048 AND length(nonce) <= 1024), + CONSTRAINT telegram_login_requests_pkce_check CHECK ( + (response_type = 'code' AND code_challenge_method = 'S256' AND length(code_challenge) BETWEEN 43 AND 128) + OR (response_type = 'post_message' AND ( + (code_challenge = '' AND code_challenge_method = '') + OR (code_challenge_method = 'S256' AND length(code_challenge) BETWEEN 43 AND 128))) + OR (response_type = 'legacy_url' AND code_challenge = '' AND code_challenge_method = '') + ), + CONSTRAINT telegram_login_requests_device_check + CHECK (length(browser) BETWEEN 1 AND 255 AND length(platform) BETWEEN 1 AND 255 AND length(ip) BETWEEN 1 AND 128 AND length(region) BETWEEN 1 AND 255), + CONSTRAINT telegram_login_requests_match_codes_check + CHECK (cardinality(match_codes) <= 8 + AND (cardinality(match_codes) = 0 OR (match_code <> '' AND match_code = ANY(match_codes))) + AND (NOT match_codes_first OR cardinality(match_codes) > 0)), + CONSTRAINT telegram_login_requests_context_check + CHECK (user_id_hint >= 0 AND peer_id >= 0 AND message_id >= 0 AND button_id >= 0), + CONSTRAINT telegram_login_requests_app_shape_check CHECK ( + (source = 'native' AND is_app AND verified_app_name <> '' AND origin = '') + OR (source <> 'native' AND NOT is_app AND verified_app_name = '' AND origin <> '') + ), + CONSTRAINT telegram_login_requests_in_app_shape_check CHECK ( + (source = 'mini_app' AND response_type = 'post_message' + AND in_app_origin <> '' AND origin = in_app_origin) + OR (source <> 'mini_app' AND in_app_origin = '') + ), + CONSTRAINT telegram_login_requests_consent_scope_check CHECK ( + (NOT write_allowed OR 'telegram:bot_access' = ANY(requested_scopes)) + AND (NOT phone_shared OR 'phone' = ANY(requested_scopes)) + AND ((phone_shared AND phone_number <> '') OR (NOT phone_shared AND phone_number = '')) + ), + CONSTRAINT telegram_login_requests_status_check CHECK (status IN ('pending','approved','declined','expired')), + CONSTRAINT telegram_login_requests_claims_check CHECK ( + length(profile_name) <= 255 AND length(given_name) <= 255 AND length(family_name) <= 255 + AND length(preferred_username) <= 64 AND length(picture) <= 4096 AND length(phone_number) <= 32 + ), + CONSTRAINT telegram_login_requests_time_check CHECK (expires_at > created_at), + CONSTRAINT telegram_login_requests_terminal_shape_check CHECK ( + (status = 'pending' AND authorized_user_id IS NULL AND profile_name = '' AND given_name = '' + AND family_name = '' AND preferred_username = '' AND picture = '' AND phone_number = '' + AND NOT write_allowed AND NOT phone_shared AND approved_at IS NULL AND declined_at IS NULL) + OR (status = 'approved' AND authorized_user_id IS NOT NULL AND approved_at IS NOT NULL AND declined_at IS NULL + AND (('profile' = ANY(requested_scopes) AND profile_name <> '' AND given_name <> '') + OR (NOT ('profile' = ANY(requested_scopes)) AND profile_name = '' AND given_name = '' + AND family_name = '' AND preferred_username = '' AND picture = ''))) + OR (status = 'declined' AND authorized_user_id IS NULL AND profile_name = '' AND given_name = '' + AND family_name = '' AND preferred_username = '' AND picture = '' AND phone_number = '' + AND NOT write_allowed AND NOT phone_shared AND approved_at IS NULL AND declined_at IS NOT NULL) + OR (status = 'expired' AND authorized_user_id IS NULL AND profile_name = '' AND given_name = '' + AND family_name = '' AND preferred_username = '' AND picture = '' AND phone_number = '' + AND NOT write_allowed AND NOT phone_shared AND approved_at IS NULL AND declined_at IS NULL) + ) +); + +CREATE INDEX telegram_login_requests_expiry_idx + ON public.telegram_login_requests(expires_at, id) WHERE status = 'pending'; +CREATE INDEX telegram_login_requests_user_active_idx + ON public.telegram_login_requests(authorized_user_id, approved_at DESC, id DESC) + WHERE status = 'approved'; + +CREATE TABLE public.web_authorizations ( + hash bigint PRIMARY KEY, + request_id bigint NOT NULL UNIQUE REFERENCES public.telegram_login_requests(id) ON DELETE CASCADE, + user_id bigint NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, + bot_user_id bigint NOT NULL REFERENCES public.bots(bot_user_id) ON DELETE CASCADE, + domain text NOT NULL, + browser text NOT NULL, + platform text NOT NULL, + ip text NOT NULL, + region text NOT NULL, + granted_scopes text[] NOT NULL, + phone_shared boolean DEFAULT false NOT NULL, + bot_access_granted boolean DEFAULT false NOT NULL, + created_at timestamp with time zone NOT NULL, + last_active_at timestamp with time zone NOT NULL, + revoked_at timestamp with time zone, + CONSTRAINT web_authorizations_hash_check CHECK (hash <> 0), + CONSTRAINT web_authorizations_identity_check CHECK (user_id > 0 AND bot_user_id > 0), + CONSTRAINT web_authorizations_text_check + CHECK (length(domain) BETWEEN 1 AND 255 AND length(browser) BETWEEN 1 AND 255 + AND length(platform) BETWEEN 1 AND 255 AND length(ip) BETWEEN 1 AND 128 + AND length(region) BETWEEN 1 AND 255), + CONSTRAINT web_authorizations_scope_check + CHECK (cardinality(granted_scopes) BETWEEN 1 AND 4 AND granted_scopes @> ARRAY['openid']::text[] + AND (NOT phone_shared OR 'phone' = ANY(granted_scopes)) + AND (NOT bot_access_granted OR 'telegram:bot_access' = ANY(granted_scopes))), + CONSTRAINT web_authorizations_time_check + CHECK (last_active_at >= created_at AND (revoked_at IS NULL OR revoked_at >= created_at)) +); + +CREATE INDEX web_authorizations_user_active_page_idx + ON public.web_authorizations(user_id, last_active_at DESC, hash DESC) + WHERE revoked_at IS NULL; +CREATE INDEX web_authorizations_bot_active_idx + ON public.web_authorizations(bot_user_id, user_id, hash) + WHERE revoked_at IS NULL; + +CREATE TABLE public.telegram_login_codes ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + request_id bigint NOT NULL UNIQUE REFERENCES public.telegram_login_requests(id) ON DELETE CASCADE, + code_hash bytea NOT NULL UNIQUE, + sealed_code bytea NOT NULL, + seal_nonce bytea NOT NULL, + seal_key_id text NOT NULL, + issued_at timestamp with time zone NOT NULL, + expires_at timestamp with time zone NOT NULL, + consumed_at timestamp with time zone, + CONSTRAINT telegram_login_codes_hash_check CHECK (octet_length(code_hash) = 32), + CONSTRAINT telegram_login_codes_sealed_check + CHECK (octet_length(sealed_code) >= 32 AND octet_length(seal_nonce) >= 12 AND length(seal_key_id) BETWEEN 1 AND 128), + CONSTRAINT telegram_login_codes_time_check + CHECK (expires_at > issued_at AND (consumed_at IS NULL OR (consumed_at >= issued_at AND consumed_at < expires_at))) +); + +CREATE INDEX telegram_login_codes_expiry_idx + ON public.telegram_login_codes(expires_at, id) WHERE consumed_at IS NULL; diff --git a/go.mod b/go.mod index 8dae72dc..21f7e95f 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module telesrv go 1.25.0 require ( + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 github.com/fxamacker/cbor/v2 v2.8.0 github.com/go-faster/errors v0.7.1 github.com/golang-migrate/migrate/v4 v4.19.1 @@ -11,6 +12,7 @@ require ( github.com/iamxvbaba/td v1.1.3 github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa github.com/jackc/pgx/v5 v5.9.2 + github.com/lestrrat-go/jwx/v3 v3.1.1 github.com/pion/datachannel v1.6.2 github.com/pion/dtls/v3 v3.1.4 github.com/pion/ice/v4 v4.2.7 @@ -43,6 +45,7 @@ require ( github.com/go-faster/jx v1.2.0 // indirect github.com/go-faster/xor v1.0.0 // indirect github.com/go-faster/yaml v0.4.6 // indirect + github.com/goccy/go-json v0.10.6 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gotd/log v0.1.0 // indirect github.com/gotd/neo v0.1.5 // indirect @@ -52,6 +55,12 @@ require ( github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/klauspost/compress v1.19.0 // indirect + github.com/lestrrat-go/blackmagic v1.0.4 // indirect + github.com/lestrrat-go/dsig v1.2.1 // indirect + github.com/lestrrat-go/dsig-secp256k1 v1.0.0 // indirect + github.com/lestrrat-go/httpcc v1.0.1 // indirect + github.com/lestrrat-go/httprc/v3 v3.0.5 // indirect + github.com/lestrrat-go/option/v2 v2.0.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.22 // indirect github.com/mitchellh/mapstructure v1.4.1 // indirect @@ -64,6 +73,7 @@ require ( github.com/segmentio/asm v1.2.1 // indirect github.com/shopspring/decimal v1.4.0 // indirect github.com/sirupsen/logrus v1.9.3 // indirect + github.com/valyala/fastjson v1.6.10 // indirect github.com/wlynxg/anet v0.0.5 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/yuin/goldmark v1.8.2 // indirect diff --git a/go.sum b/go.sum index eeed5c28..d3c16d1d 100644 --- a/go.sum +++ b/go.sum @@ -22,6 +22,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4= github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= @@ -57,6 +59,8 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= +github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA= @@ -98,6 +102,20 @@ github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA= +github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw= +github.com/lestrrat-go/dsig v1.2.1 h1:MwxzZhE4+4fguHi+uDALKVlC3Cn+O1QU1Q/F8D7hVIc= +github.com/lestrrat-go/dsig v1.2.1/go.mod h1:RD2eOaidyPvpc7IJQoO3Qq52RWdy8ZcJs8lrOnoa1Kc= +github.com/lestrrat-go/dsig-secp256k1 v1.0.0 h1:JpDe4Aybfl0soBvoVwjqDbp+9S1Y2OM7gcrVVMFPOzY= +github.com/lestrrat-go/dsig-secp256k1 v1.0.0/go.mod h1:CxUgAhssb8FToqbL8NjSPoGQlnO4w3LG1P0qPWQm/NU= +github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= +github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= +github.com/lestrrat-go/httprc/v3 v3.0.5 h1:S+Mb4L2I+bM6JGTibLmxExhyTOqnXjqx+zi9MoXw/TM= +github.com/lestrrat-go/httprc/v3 v3.0.5/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0= +github.com/lestrrat-go/jwx/v3 v3.1.1 h1:yd9AdPmZ4INnQ7k42IrzXYpnEG803+SrQ6hdMvzHJzw= +github.com/lestrrat-go/jwx/v3 v3.1.1/go.mod h1:uw/MN2M/Xiu4FhwcIwH11Zsh9JWx9SWzgALl7/uIEkU= +github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss= +github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= @@ -175,6 +193,8 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4= +github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE= github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= diff --git a/internal/app/bots/botfather.go b/internal/app/bots/botfather.go index d49bdc62..d37d492f 100644 --- a/internal/app/bots/botfather.go +++ b/internal/app/bots/botfather.go @@ -12,6 +12,7 @@ import ( "go.uber.org/zap" + telegramloginapp "telesrv/internal/app/telegramlogin" "telesrv/internal/branding" "telesrv/internal/domain" ) @@ -33,6 +34,9 @@ const ( botFatherCmdSetInlineFB = "setinlinefeedback" botFatherCmdSetJoinGroups = "setjoingroups" botFatherCmdSetPrivacy = "setprivacy" + botFatherCmdSetLogin = "setlogin" + botFatherCmdLoginInfo = "logininfo" + botFatherCmdResetLogin = "resetloginsecret" botFatherStepName = "name" botFatherStepUsername = "username" @@ -60,6 +64,9 @@ You can control me by sending these commands: /setinlinefeedback - change inline feedback settings /setjoingroups - toggle whether a bot can join groups /setprivacy - toggle a bot's group privacy mode +/setlogin - configure Telegram Login allowed URLs and signing +/logininfo - show a bot's Telegram Login configuration +/resetloginsecret - rotate a bot's OIDC Client Secret /cancel - cancel the current operation /help - show this message` @@ -175,6 +182,7 @@ var botFatherGlobalCommands = map[string]bool{ botFatherCmdSetName: true, botFatherCmdSetDescription: true, botFatherCmdSetAbout: true, botFatherCmdSetCommands: true, botFatherCmdSetInline: true, botFatherCmdSetInlineGeo: true, botFatherCmdSetInlineFB: true, botFatherCmdSetJoinGroups: true, botFatherCmdSetPrivacy: true, + botFatherCmdSetLogin: true, botFatherCmdLoginInfo: true, botFatherCmdResetLogin: true, } func (s *Service) handleBotFather(ctx context.Context, userID int64, text string) botReply { @@ -231,6 +239,9 @@ var pickerPrompts = map[string]string{ botFatherCmdSetInlineGeo: "Choose a bot to change inline location requests for. Send the bot's username:", botFatherCmdSetJoinGroups: "Choose a bot to configure group joining for. Send the bot's username:", botFatherCmdSetPrivacy: "Choose a bot to configure group privacy for. Send the bot's username:", + botFatherCmdSetLogin: "Choose a bot to configure Telegram Login for. Send the bot's username:", + botFatherCmdLoginInfo: "Choose a bot whose Telegram Login configuration you want to inspect:", + botFatherCmdResetLogin: "Choose a bot whose OIDC Client Secret you want to rotate:", } // startBotPicker 列出 owner 的 bot 并进入 choose step(所有需先选 bot 的命令共用)。 @@ -322,7 +333,8 @@ func (s *Service) handleBotFatherCommand(ctx context.Context, userID int64, cmd case botFatherCmdToken, botFatherCmdRevoke, botFatherCmdSetName, botFatherCmdSetDescription, botFatherCmdSetAbout, botFatherCmdSetCommands, botFatherCmdSetInline, botFatherCmdSetInlineGeo, - botFatherCmdSetJoinGroups, botFatherCmdSetPrivacy: + botFatherCmdSetJoinGroups, botFatherCmdSetPrivacy, + botFatherCmdSetLogin, botFatherCmdLoginInfo, botFatherCmdResetLogin: return s.startBotPicker(ctx, userID, cmd) case botFatherCmdSetInlineFB: _ = s.bots.DeleteBotChatState(ctx, domain.BotFatherUserID, userID) @@ -351,6 +363,8 @@ func valuePrompt(cmd, username string) string { return fmt.Sprintf("Send 'enable' to allow @%s to be added to groups, or 'disable' to prevent it.", username) case botFatherCmdSetPrivacy: return fmt.Sprintf("Send 'enable' to turn ON group privacy for @%s (it will only receive commands and replies), or 'disable' to let it receive all group messages.", username) + case botFatherCmdSetLogin: + return telegramLoginConfigurationPrompt(username) default: return "Send the new value, or /cancel." } @@ -445,6 +459,61 @@ func (s *Service) handleChooseBot(ctx context.Context, state domain.BotChatState } head := fmt.Sprintf("Token for @%s has been revoked. The old token will stop working immediately. New token:\n", chosen.Username) return tokenReply(head, token, "\n\nKeep your token secure and store it safely, it can be used by anyone to control your bot.") + case botFatherCmdLoginInfo: + defer s.clearState(ctx, state.UserID) + if s.telegramLogin == nil { + return botReply{Text: "Telegram Login is not enabled on this server."} + } + configuration, found, err := s.telegramLogin.ClientConfiguration(ctx, chosen.ID) + if err != nil { + s.log.Error("botfather: get telegram login configuration", zap.Int64("bot_user_id", chosen.ID), zap.Error(err)) + return internalReply() + } + if !found { + return botReply{Text: fmt.Sprintf("Telegram Login is not configured for @%s. Use /setlogin to create it.", chosen.Username)} + } + return botReply{Text: formatTelegramLoginConfiguration(chosen.Username, configuration)} + case botFatherCmdResetLogin: + defer s.clearState(ctx, state.UserID) + if s.telegramLogin == nil { + return botReply{Text: "Telegram Login is not enabled on this server."} + } + credentials, err := s.telegramLogin.RotateClientSecret(ctx, chosen.ID) + if errors.Is(err, domain.ErrTelegramLoginClientInvalid) { + return botReply{Text: fmt.Sprintf("Telegram Login is not configured for @%s. Use /setlogin first.", chosen.Username)} + } + if err != nil { + s.log.Error("botfather: rotate telegram login secret", zap.Int64("bot_user_id", chosen.ID), zap.Error(err)) + return internalReply() + } + head := fmt.Sprintf("The previous OIDC Client Secret for @%s is now invalid. Save this new secret; it will only be shown once:\n", chosen.Username) + return tokenReply(head, credentials.Secret, "\n\nClient ID: "+credentials.Client.ClientID) + case botFatherCmdSetLogin: + if s.telegramLogin == nil { + s.clearState(ctx, state.UserID) + return botReply{Text: "Telegram Login is not enabled on this server."} + } + credentials, created, err := s.telegramLogin.EnsureClient(ctx, chosen.ID) + if err != nil { + s.log.Error("botfather: ensure telegram login client", zap.Int64("bot_user_id", chosen.ID), zap.Error(err)) + return internalReply() + } + state.Step = botFatherStepValue + if state.Draft == nil { + state.Draft = map[string]string{} + } + state.Draft[botFatherDraftBotID] = strconv.FormatInt(chosen.ID, 10) + state.Draft[botFatherDraftBotUsername] = chosen.Username + if err := s.bots.UpsertBotChatState(ctx, state); err != nil { + s.log.Error("botfather: save telegram login state", zap.Int64("user_id", state.UserID), zap.Error(err)) + return internalReply() + } + prompt := telegramLoginConfigurationPrompt(chosen.Username) + if !created { + return botReply{Text: fmt.Sprintf("Telegram Login client %s is ready for @%s.\n\n%s", credentials.Client.ClientID, chosen.Username, prompt)} + } + head := fmt.Sprintf("Telegram Login is now enabled for @%s.\nClient ID: %s\nSave this Client Secret; it will only be shown once:\n", chosen.Username, credentials.Client.ClientID) + return tokenReply(head, credentials.Secret, "\n\n"+prompt) case botFatherCmdSetName, botFatherCmdSetDescription, botFatherCmdSetAbout, botFatherCmdSetCommands, botFatherCmdSetInline, botFatherCmdSetInlineGeo, botFatherCmdSetJoinGroups, botFatherCmdSetPrivacy: @@ -507,6 +576,8 @@ func (s *Service) handleSetValue(ctx context.Context, state domain.BotChatState, reply, err = s.applyToggle(ctx, botID, text, true) case botFatherCmdSetPrivacy: reply, err = s.applyToggle(ctx, botID, text, false) + case botFatherCmdSetLogin: + reply, err = s.applyTelegramLoginConfiguration(ctx, botID, username, text) default: s.clearState(ctx, state.UserID) return internalReply() @@ -583,6 +654,156 @@ func (s *Service) applySetInlineGeo(ctx context.Context, botID int64, text strin return botReply{Text: fmt.Sprintf("Success! Inline location requests are now %s.", state)}, nil } +func telegramLoginConfigurationPrompt(username string) string { + return fmt.Sprintf(`Send one configuration command for @%s: + +add origin https://example.com +add redirect https://example.com/auth/callback +add ios com.example.app ABCDE12345 exampleapp://tglogin Example iOS App +add android com.example.app AA:BB:...:FF exampleapp://telegram-login Example Android App +remove origin https://example.com +remove redirect https://example.com/auth/callback +remove app 12 +algorithm RS256|ES256|EdDSA|ES256K +enable +disable + +Origins authorize the JS SDK and legacy login_url buttons. Redirects are exact OIDC callbacks. Run /logininfo to inspect the result or /cancel to stop.`, username) +} + +func formatTelegramLoginConfiguration(username string, configuration telegramloginapp.ClientConfiguration) string { + status := "disabled" + if configuration.Client.Enabled { + status = "enabled" + } + var out strings.Builder + fmt.Fprintf(&out, "Telegram Login for @%s\nClient ID: %s\nStatus: %s\nSigning algorithm: %s\nSecret version: %d", + username, configuration.Client.ClientID, status, configuration.Client.SigningAlgorithm, configuration.Client.SecretVersion) + if len(configuration.AllowedURLs) == 0 { + out.WriteString("\nAllowed URLs: none") + } else { + out.WriteString("\nAllowed URLs:") + for _, allowed := range configuration.AllowedURLs { + fmt.Fprintf(&out, "\n- %s %s", allowed.Kind, allowed.NormalizedURL) + } + } + if len(configuration.NativeApps) > 0 { + out.WriteString("\nNative apps:") + for _, app := range configuration.NativeApps { + fmt.Fprintf(&out, "\n- #%d %s %s [%s] -> %s (%s)", app.ID, app.Platform, app.ApplicationID, app.VerificationID, app.CallbackURI, app.VerifiedDisplayName) + } + } + return out.String() +} + +func telegramLoginAllowedURLKind(raw string) (domain.TelegramLoginAllowedURLKind, bool) { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "origin": + return domain.TelegramLoginAllowedWebOrigin, true + case "redirect": + return domain.TelegramLoginAllowedRedirectURI, true + default: + return "", false + } +} + +func telegramLoginSigningAlgorithm(raw string) (domain.TelegramLoginSigningAlgorithm, bool) { + switch strings.ToUpper(strings.TrimSpace(raw)) { + case "RS256": + return domain.TelegramLoginSigningRS256, true + case "ES256": + return domain.TelegramLoginSigningES256, true + case "EDDSA": + return domain.TelegramLoginSigningEdDSA, true + case "ES256K": + return domain.TelegramLoginSigningES256K, true + default: + return "", false + } +} + +func (s *Service) applyTelegramLoginConfiguration(ctx context.Context, botID int64, username, text string) (botReply, error) { + if s.telegramLogin == nil { + return botReply{Text: "Telegram Login is not enabled on this server."}, domain.ErrTelegramLoginClientDisabled + } + fields := strings.Fields(strings.TrimSpace(text)) + if len(fields) == 1 { + switch strings.ToLower(fields[0]) { + case "enable": + if err := s.telegramLogin.SetClientEnabled(ctx, botID, true); err != nil { + return botReply{}, err + } + return botReply{Text: fmt.Sprintf("Telegram Login is enabled for @%s. Use /setlogin for another change or /logininfo to review it.", username)}, nil + case "disable": + if err := s.telegramLogin.SetClientEnabled(ctx, botID, false); err != nil { + return botReply{}, err + } + return botReply{Text: fmt.Sprintf("Telegram Login is disabled for @%s. Pending requests can no longer be approved or exchanged.", username)}, nil + } + } + if len(fields) == 2 && strings.EqualFold(fields[0], "algorithm") { + algorithm, ok := telegramLoginSigningAlgorithm(fields[1]) + if !ok { + return botReply{Text: "Unknown signing algorithm. Use RS256, ES256, EdDSA or ES256K, or /cancel."}, domain.ErrTelegramLoginClientInvalid + } + if _, err := s.telegramLogin.SetClientSigningAlgorithm(ctx, botID, algorithm); err != nil { + if errors.Is(err, domain.ErrTelegramLoginClientInvalid) { + return botReply{Text: fmt.Sprintf("%s is not available on this server because no active signing key is configured for it. Choose another algorithm or ask the operator to rotate the key ring.", algorithm)}, err + } + return botReply{}, err + } + return botReply{Text: fmt.Sprintf("Success! New ID tokens for @%s will use %s. EdDSA and ES256K accept only the openid scope.", username, algorithm)}, nil + } + if len(fields) == 3 && (strings.EqualFold(fields[0], "add") || strings.EqualFold(fields[0], "remove")) && + (strings.EqualFold(fields[1], "origin") || strings.EqualFold(fields[1], "redirect")) { + kind, ok := telegramLoginAllowedURLKind(fields[1]) + if !ok { + return botReply{Text: "URL kind must be origin or redirect. Try again or /cancel."}, domain.ErrTelegramLoginURLInvalid + } + if strings.EqualFold(fields[0], "add") { + allowed, err := s.telegramLogin.AddAllowedURL(ctx, botID, kind, fields[2]) + if err != nil { + return botReply{Text: "That URL is not allowed. Use an exact HTTPS URL without credentials, fragments or reserved OAuth query fields."}, err + } + return botReply{Text: fmt.Sprintf("Success! Added %s for @%s:\n%s", allowed.Kind, username, allowed.NormalizedURL)}, nil + } + deleted, err := s.telegramLogin.DeleteAllowedURL(ctx, botID, kind, fields[2]) + if err != nil { + return botReply{Text: "That URL is invalid. Try again or /cancel."}, err + } + if !deleted { + return botReply{Text: "That exact URL was not registered. Check /logininfo and try again."}, domain.ErrTelegramLoginURLInvalid + } + return botReply{Text: fmt.Sprintf("Success! Removed %s from @%s.", kind, username)}, nil + } + if len(fields) >= 6 && strings.EqualFold(fields[0], "add") && (strings.EqualFold(fields[1], "ios") || strings.EqualFold(fields[1], "android")) { + platform := domain.TelegramLoginNativeIOS + if strings.EqualFold(fields[1], "android") { + platform = domain.TelegramLoginNativeAndroid + } + app, err := s.telegramLogin.AddNativeApp(ctx, botID, platform, fields[2], fields[3], fields[4], strings.Join(fields[5:], " ")) + if err != nil { + return botReply{Text: "Invalid native app registration. iOS needs Bundle ID + 10-character Team ID; Android needs package name + SHA-256 signing fingerprint. Use an exact HTTPS callback or a custom scheme://host callback."}, err + } + return botReply{Text: fmt.Sprintf("Success! Registered native app #%d for @%s:\n%s %s -> %s", app.ID, username, app.Platform, app.ApplicationID, app.CallbackURI)}, nil + } + if len(fields) == 3 && strings.EqualFold(fields[0], "remove") && strings.EqualFold(fields[1], "app") { + appID, err := strconv.ParseInt(fields[2], 10, 64) + if err != nil || appID <= 0 { + return botReply{Text: "Native app ID must be the positive number shown by /logininfo."}, domain.ErrTelegramLoginClientInvalid + } + deleted, err := s.telegramLogin.DeleteNativeApp(ctx, botID, appID) + if err != nil { + return botReply{}, err + } + if !deleted { + return botReply{Text: "That native app was not registered for this bot. Check /logininfo."}, domain.ErrTelegramLoginClientInvalid + } + return botReply{Text: fmt.Sprintf("Success! Removed native app #%d from @%s.", appID, username)}, nil + } + return botReply{Text: telegramLoginConfigurationPrompt(username)}, domain.ErrTelegramLoginRequestInvalid +} + // applyToggle 解析 enable/disable 并设置 joingroups(join=true)或 privacy(join=false)。 func (s *Service) applyToggle(ctx context.Context, botID int64, text string, join bool) (botReply, error) { var on bool diff --git a/internal/app/bots/botfather_login_test.go b/internal/app/bots/botfather_login_test.go new file mode 100644 index 00000000..8a77f63c --- /dev/null +++ b/internal/app/bots/botfather_login_test.go @@ -0,0 +1,100 @@ +package bots + +import ( + "context" + "strconv" + "strings" + "testing" + "time" + + telegramloginapp "telesrv/internal/app/telegramlogin" + "telesrv/internal/store/memory" +) + +func newBotFatherTelegramLoginService(t *testing.T) *telegramloginapp.Service { + t.Helper() + sealKey := make([]byte, 32) + sealKey[0] = 1 + sealer, err := telegramloginapp.NewCodeSealer("test", map[string][]byte{"test": sealKey}) + if err != nil { + t.Fatal(err) + } + pepper := make([]byte, 32) + pepper[0] = 2 + service, err := telegramloginapp.NewService(memory.NewTelegramLoginStore(nil), sealer, telegramloginapp.Config{ + Issuer: "http://localhost:2404", AppScheme: "telesrv", AllowLoopbackHTTP: true, + ClientSecretPepper: pepper, Now: func() time.Time { return time.Unix(1_780_000_000, 0).UTC() }, + }) + if err != nil { + t.Fatal(err) + } + return service +} + +func TestBotFatherTelegramLoginConfigurationFlow(t *testing.T) { + svc, users, _, messages := newTestService(t) + svc.telegramLogin = newBotFatherTelegramLoginService(t) + owner := newOwner(t, users, "+1090") + bot, _, err := svc.CreateBot(context.Background(), owner.ID, "Login Demo", "login_demo_bot") + if err != nil { + t.Fatal(err) + } + + if reply := sendToBotFather(t, svc, messages, owner, "/setlogin"); !strings.Contains(reply, "Choose a bot") { + t.Fatalf("/setlogin reply = %q", reply) + } + created := sendToBotFather(t, svc, messages, owner, "@login_demo_bot") + if !strings.Contains(created, "Client ID: "+strconv.FormatInt(bot.ID, 10)) || !strings.Contains(created, "only be shown once") { + t.Fatalf("create login reply = %q", created) + } + secretMarker := "only be shown once:\n" + secret := strings.SplitN(strings.SplitN(created, secretMarker, 2)[1], "\n", 2)[0] + if len(secret) < 32 { + t.Fatalf("client secret is unexpectedly short: %q", secret) + } + if reply := sendToBotFather(t, svc, messages, owner, "add origin http://localhost:3000"); !strings.Contains(reply, "Success!") { + t.Fatalf("add origin reply = %q", reply) + } + + sendToBotFather(t, svc, messages, owner, "/setlogin") + sendToBotFather(t, svc, messages, owner, "login_demo_bot") + if reply := sendToBotFather(t, svc, messages, owner, "add redirect http://localhost:3000/auth/callback"); !strings.Contains(reply, "Success!") { + t.Fatalf("add redirect reply = %q", reply) + } + + sendToBotFather(t, svc, messages, owner, "/setlogin") + sendToBotFather(t, svc, messages, owner, "login_demo_bot") + if reply := sendToBotFather(t, svc, messages, owner, "algorithm ES256"); !strings.Contains(reply, "ES256") { + t.Fatalf("algorithm reply = %q", reply) + } + + sendToBotFather(t, svc, messages, owner, "/setlogin") + sendToBotFather(t, svc, messages, owner, "login_demo_bot") + if reply := sendToBotFather(t, svc, messages, owner, "add ios dev.bedolaga.demo ABCDE12345 bedolaga://telegram-login Bedolaga iOS Demo"); !strings.Contains(reply, "Registered native app #") { + t.Fatalf("add iOS app reply = %q", reply) + } + + sendToBotFather(t, svc, messages, owner, "/setlogin") + sendToBotFather(t, svc, messages, owner, "login_demo_bot") + fingerprint := strings.Repeat("A", 64) + if reply := sendToBotFather(t, svc, messages, owner, "add android dev.bedolaga.demo "+fingerprint+" bedolaga://android-login Bedolaga Android Demo"); !strings.Contains(reply, "Registered native app #") { + t.Fatalf("add Android app reply = %q", reply) + } + + sendToBotFather(t, svc, messages, owner, "/logininfo") + info := sendToBotFather(t, svc, messages, owner, "login_demo_bot") + for _, want := range []string{"Signing algorithm: ES256", "web_origin http://localhost:3000", "redirect_uri http://localhost:3000/auth/callback", "dev.bedolaga.demo", "Bedolaga iOS Demo", "Bedolaga Android Demo"} { + if !strings.Contains(info, want) { + t.Fatalf("login info = %q, missing %q", info, want) + } + } + if strings.Contains(info, secret) { + t.Fatal("/logininfo leaked the one-time client secret") + } + + sendToBotFather(t, svc, messages, owner, "/resetloginsecret") + rotated := sendToBotFather(t, svc, messages, owner, "login_demo_bot") + if !strings.Contains(rotated, "previous OIDC Client Secret") || strings.Contains(rotated, secret) { + t.Fatalf("rotate reply = %q", rotated) + } +} diff --git a/internal/app/bots/service.go b/internal/app/bots/service.go index b715acfc..a610b8cd 100644 --- a/internal/app/bots/service.go +++ b/internal/app/bots/service.go @@ -16,6 +16,7 @@ import ( "go.uber.org/zap" + telegramloginapp "telesrv/internal/app/telegramlogin" "telesrv/internal/domain" "telesrv/internal/links" "telesrv/internal/store" @@ -82,6 +83,7 @@ type Service struct { stickers stickerSetCreator installer userStickerSetInstaller aiChat aiChatGenerator + telegramLogin *telegramloginapp.Service hooks RouterHooks textDrafts TextDraftPusher userCache store.UserCache @@ -175,6 +177,16 @@ func WithAIChatGenerator(g aiChatGenerator) Option { } } +// WithTelegramLogin injects the OIDC application service used by BotFather. +// BotFather never writes the login tables directly. +func WithTelegramLogin(login *telegramloginapp.Service) Option { + return func(s *Service) { + if login != nil { + s.telegramLogin = login + } + } +} + // WithAIChatStreamThrottle 调整 @ChatBot 流式草稿推送的最小时间间隔(测试用)。 func WithAIChatStreamThrottle(d time.Duration) Option { return func(s *Service) { diff --git a/internal/app/telegramlogin/crypto.go b/internal/app/telegramlogin/crypto.go new file mode 100644 index 00000000..44f8e650 --- /dev/null +++ b/internal/app/telegramlogin/crypto.go @@ -0,0 +1,131 @@ +package telegramlogin + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "errors" + "fmt" + + "telesrv/internal/domain" +) + +const opaqueTokenBytes = 32 + +func GenerateOpaqueToken() (string, error) { + raw := make([]byte, opaqueTokenBytes) + if _, err := rand.Read(raw); err != nil { + return "", fmt.Errorf("generate opaque token: %w", err) + } + return base64.RawURLEncoding.EncodeToString(raw), nil +} + +func HashOpaqueToken(token string) []byte { + sum := sha256.Sum256([]byte(token)) + return sum[:] +} + +func HashClientSecret(pepper []byte, secret string) ([]byte, error) { + if len(pepper) < 32 || secret == "" { + return nil, domain.ErrTelegramLoginSecretInvalid + } + mac := hmac.New(sha256.New, pepper) + _, _ = mac.Write([]byte(secret)) + return mac.Sum(nil), nil +} + +func VerifyClientSecret(pepper []byte, secret string, expected []byte) bool { + actual, err := HashClientSecret(pepper, secret) + if err != nil || len(expected) != sha256.Size { + return false + } + return subtle.ConstantTimeCompare(actual, expected) == 1 +} + +func PKCEChallenge(verifier string) (string, error) { + if len(verifier) < 43 || len(verifier) > 128 { + return "", domain.ErrTelegramLoginPKCEInvalid + } + for i := 0; i < len(verifier); i++ { + c := verifier[i] + if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '.' || c == '_' || c == '~') { + return "", domain.ErrTelegramLoginPKCEInvalid + } + } + sum := sha256.Sum256([]byte(verifier)) + return base64.RawURLEncoding.EncodeToString(sum[:]), nil +} + +func ValidatePKCEChallenge(challenge, method string) error { + if method != "S256" || len(challenge) < 43 || len(challenge) > 128 { + return domain.ErrTelegramLoginPKCEInvalid + } + for i := 0; i < len(challenge); i++ { + c := challenge[i] + if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_') { + return domain.ErrTelegramLoginPKCEInvalid + } + } + return nil +} + +type CodeSealer struct { + activeKeyID string + keys map[string]cipher.AEAD +} + +func NewCodeSealer(activeKeyID string, rawKeys map[string][]byte) (*CodeSealer, error) { + if activeKeyID == "" || len(rawKeys) == 0 { + return nil, errors.New("telegram login code seal key ring is empty") + } + keys := make(map[string]cipher.AEAD, len(rawKeys)) + for keyID, raw := range rawKeys { + if keyID == "" || len(raw) != 32 { + return nil, fmt.Errorf("invalid telegram login code seal key %q", keyID) + } + block, err := aes.NewCipher(raw) + if err != nil { + return nil, fmt.Errorf("create telegram login code seal key %q: %w", keyID, err) + } + aead, err := cipher.NewGCM(block) + if err != nil { + return nil, fmt.Errorf("create telegram login code sealer %q: %w", keyID, err) + } + keys[keyID] = aead + } + if _, ok := keys[activeKeyID]; !ok { + return nil, fmt.Errorf("active telegram login code seal key %q not found", activeKeyID) + } + return &CodeSealer{activeKeyID: activeKeyID, keys: keys}, nil +} + +func (s *CodeSealer) Seal(plaintext string, aad []byte) (sealed, nonce []byte, keyID string, err error) { + if s == nil || plaintext == "" { + return nil, nil, "", domain.ErrTelegramLoginCodeInvalid + } + aead := s.keys[s.activeKeyID] + nonce = make([]byte, aead.NonceSize()) + if _, err := rand.Read(nonce); err != nil { + return nil, nil, "", fmt.Errorf("generate telegram login code nonce: %w", err) + } + return aead.Seal(nil, nonce, []byte(plaintext), aad), nonce, s.activeKeyID, nil +} + +func (s *CodeSealer) Open(sealed, nonce []byte, keyID string, aad []byte) (string, error) { + if s == nil { + return "", domain.ErrTelegramLoginCodeInvalid + } + aead, ok := s.keys[keyID] + if !ok || len(nonce) != aead.NonceSize() { + return "", domain.ErrTelegramLoginCodeInvalid + } + plaintext, err := aead.Open(nil, nonce, sealed, aad) + if err != nil || len(plaintext) == 0 { + return "", domain.ErrTelegramLoginCodeInvalid + } + return string(plaintext), nil +} diff --git a/internal/app/telegramlogin/jose.go b/internal/app/telegramlogin/jose.go new file mode 100644 index 00000000..60986af6 --- /dev/null +++ b/internal/app/telegramlogin/jose.go @@ -0,0 +1,406 @@ +package telegramlogin + +import ( + "crypto" + "crypto/ecdsa" + "crypto/ed25519" + "crypto/elliptic" + "crypto/rsa" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/lestrrat-go/jwx/v3/jwa" + "github.com/lestrrat-go/jwx/v3/jwk" + "github.com/lestrrat-go/jwx/v3/jwt" + + "telesrv/internal/domain" +) + +const defaultIDTokenTTL = time.Hour + +type SigningKeyMaterial struct { + Algorithm domain.TelegramLoginSigningAlgorithm + KeyID string + PrivateKey any + Active bool + PublishUntil time.Time +} + +type signingKey struct { + algorithm domain.TelegramLoginSigningAlgorithm + jwaAlgorithm jwa.SignatureAlgorithm + keyID string + private jwk.Key + public jwk.Key + active bool + publishUntil time.Time +} + +// SigningKeyRing owns no mutable crypto state. Rotation is performed by +// constructing a new ring containing the new active key and old public keys +// with a PublishUntil at least as long as the maximum ID-token lifetime. +type SigningKeyRing struct { + keys []signingKey + active map[domain.TelegramLoginSigningAlgorithm]signingKey + now func() time.Time +} + +func NewSigningKeyRing(materials []SigningKeyMaterial, now func() time.Time) (*SigningKeyRing, error) { + if len(materials) == 0 { + return nil, errors.New("telegram login signing key ring is empty") + } + if now == nil { + now = time.Now + } + ring := &SigningKeyRing{ + keys: make([]signingKey, 0, len(materials)), + active: make(map[domain.TelegramLoginSigningAlgorithm]signingKey), + now: now, + } + seenKeyIDs := make(map[string]struct{}, len(materials)) + for _, material := range materials { + key, err := importSigningKey(material) + if err != nil { + return nil, err + } + if _, duplicate := seenKeyIDs[key.keyID]; duplicate { + return nil, fmt.Errorf("duplicate telegram login signing kid %q", key.keyID) + } + seenKeyIDs[key.keyID] = struct{}{} + if key.active { + if _, duplicate := ring.active[key.algorithm]; duplicate { + return nil, fmt.Errorf("multiple active telegram login signing keys for %s", key.algorithm) + } + ring.active[key.algorithm] = key + } + ring.keys = append(ring.keys, key) + } + if len(ring.active) == 0 { + return nil, errors.New("telegram login signing key ring has no active key") + } + return ring, nil +} + +func importSigningKey(material SigningKeyMaterial) (signingKey, error) { + if !material.Algorithm.Valid() || material.PrivateKey == nil { + return signingKey{}, fmt.Errorf("invalid telegram login signing key material") + } + if material.Algorithm == domain.TelegramLoginSigningES256K && !telegramLoginES256KEnabled { + return signingKey{}, errors.New("telegram login ES256K requires a build with -tags jwx_es256k") + } + if err := validateRawSigningKey(material.Algorithm, material.PrivateKey); err != nil { + return signingKey{}, err + } + privateKey, err := jwk.Import(material.PrivateKey) + if err != nil { + return signingKey{}, fmt.Errorf("import telegram login %s private key: %w", material.Algorithm, err) + } + if err := privateKey.Validate(); err != nil { + return signingKey{}, fmt.Errorf("validate telegram login %s private JWK: %w", material.Algorithm, err) + } + publicKey, err := privateKey.PublicKey() + if err != nil { + return signingKey{}, fmt.Errorf("derive telegram login %s public JWK: %w", material.Algorithm, err) + } + thumbprint, err := publicKey.Thumbprint(crypto.SHA256) + if err != nil { + return signingKey{}, fmt.Errorf("thumbprint telegram login %s public JWK: %w", material.Algorithm, err) + } + keyID := strings.TrimSpace(material.KeyID) + if keyID == "" { + keyID = base64.RawURLEncoding.EncodeToString(thumbprint) + } + if len(keyID) > 128 || strings.IndexFunc(keyID, func(r rune) bool { return r <= 0x20 || r == 0x7f }) >= 0 { + return signingKey{}, fmt.Errorf("invalid telegram login signing kid") + } + jwaAlgorithm, err := telegramLoginJWA(material.Algorithm) + if err != nil { + return signingKey{}, err + } + for _, key := range []jwk.Key{privateKey, publicKey} { + if err := key.Set(jwk.KeyIDKey, keyID); err != nil { + return signingKey{}, fmt.Errorf("set telegram login signing kid: %w", err) + } + if err := key.Set(jwk.AlgorithmKey, jwaAlgorithm); err != nil { + return signingKey{}, fmt.Errorf("set telegram login signing algorithm: %w", err) + } + if err := key.Set(jwk.KeyUsageKey, "sig"); err != nil { + return signingKey{}, fmt.Errorf("set telegram login signing use: %w", err) + } + } + return signingKey{ + algorithm: material.Algorithm, jwaAlgorithm: jwaAlgorithm, keyID: keyID, + private: privateKey, public: publicKey, active: material.Active, + publishUntil: material.PublishUntil.UTC(), + }, nil +} + +func validateRawSigningKey(algorithm domain.TelegramLoginSigningAlgorithm, raw any) error { + switch algorithm { + case domain.TelegramLoginSigningRS256: + key, ok := rsaPrivateKey(raw) + if !ok || key.N == nil || key.N.BitLen() < 2048 || key.E < 3 { + return errors.New("telegram login RS256 requires an RSA private key of at least 2048 bits") + } + if err := key.Validate(); err != nil { + return fmt.Errorf("validate telegram login RSA private key: %w", err) + } + case domain.TelegramLoginSigningES256: + key, ok := ecdsaPrivateKey(raw) + if !ok || key.Curve != elliptic.P256() || key.D == nil || key.X == nil || key.Y == nil { + return errors.New("telegram login ES256 requires a P-256 ECDSA private key") + } + case domain.TelegramLoginSigningEdDSA: + key, ok := raw.(ed25519.PrivateKey) + if !ok || len(key) != ed25519.PrivateKeySize { + return errors.New("telegram login EdDSA requires an Ed25519 private key") + } + case domain.TelegramLoginSigningES256K: + key, ok := ecdsaPrivateKey(raw) + if !ok || key.Curve == nil || key.Curve.Params() == nil || + !strings.EqualFold(key.Curve.Params().Name, "secp256k1") || key.D == nil || key.X == nil || key.Y == nil { + return errors.New("telegram login ES256K requires a secp256k1 ECDSA private key") + } + default: + return domain.ErrTelegramLoginClientInvalid + } + return nil +} + +func rsaPrivateKey(raw any) (*rsa.PrivateKey, bool) { + switch key := raw.(type) { + case *rsa.PrivateKey: + return key, key != nil + case rsa.PrivateKey: + return &key, true + default: + return nil, false + } +} + +func ecdsaPrivateKey(raw any) (*ecdsa.PrivateKey, bool) { + switch key := raw.(type) { + case *ecdsa.PrivateKey: + return key, key != nil + case ecdsa.PrivateKey: + return &key, true + default: + return nil, false + } +} + +func telegramLoginJWA(algorithm domain.TelegramLoginSigningAlgorithm) (jwa.SignatureAlgorithm, error) { + switch algorithm { + case domain.TelegramLoginSigningRS256: + return jwa.RS256(), nil + case domain.TelegramLoginSigningES256: + return jwa.ES256(), nil + case domain.TelegramLoginSigningEdDSA: + return jwa.EdDSA(), nil + case domain.TelegramLoginSigningES256K: + if telegramLoginES256KEnabled { + return jwa.ES256K(), nil + } + return jwa.EmptySignatureAlgorithm(), errors.New("telegram login ES256K is disabled in this build") + default: + return jwa.EmptySignatureAlgorithm(), domain.ErrTelegramLoginClientInvalid + } +} + +func (r *SigningKeyRing) SupportedAlgorithms() []string { + if r == nil { + return nil + } + ordered := make([]string, 0, len(r.active)) + for _, algorithm := range []domain.TelegramLoginSigningAlgorithm{ + domain.TelegramLoginSigningRS256, + domain.TelegramLoginSigningES256, + domain.TelegramLoginSigningEdDSA, + domain.TelegramLoginSigningES256K, + } { + if _, ok := r.active[algorithm]; ok { + ordered = append(ordered, string(algorithm)) + } + } + return ordered +} + +// ActiveAlgorithms returns the algorithms that can sign new tokens on this +// instance. Callers use it to prevent durable client configuration from +// selecting an algorithm without an active private key. +func (r *SigningKeyRing) ActiveAlgorithms() []domain.TelegramLoginSigningAlgorithm { + if r == nil { + return nil + } + ordered := make([]domain.TelegramLoginSigningAlgorithm, 0, len(r.active)) + for _, algorithm := range []domain.TelegramLoginSigningAlgorithm{ + domain.TelegramLoginSigningRS256, + domain.TelegramLoginSigningES256, + domain.TelegramLoginSigningEdDSA, + domain.TelegramLoginSigningES256K, + } { + if _, ok := r.active[algorithm]; ok { + ordered = append(ordered, algorithm) + } + } + return ordered +} + +func (r *SigningKeyRing) JWKS() ([]byte, string, error) { + if r == nil { + return nil, "", errors.New("telegram login signing key ring is nil") + } + now := r.now().UTC() + set := jwk.NewSet() + for _, key := range r.keys { + if !key.active && (key.publishUntil.IsZero() || !now.Before(key.publishUntil)) { + continue + } + clone, err := key.public.Clone() + if err != nil { + return nil, "", fmt.Errorf("clone telegram login public JWK: %w", err) + } + if err := set.AddKey(clone); err != nil { + return nil, "", fmt.Errorf("add telegram login public JWK: %w", err) + } + } + body, err := json.Marshal(set) + if err != nil { + return nil, "", fmt.Errorf("marshal telegram login JWKS: %w", err) + } + sum := sha256.Sum256(body) + return body, `"` + base64.RawURLEncoding.EncodeToString(sum[:]) + `"`, nil +} + +func (r *SigningKeyRing) sign(algorithm domain.TelegramLoginSigningAlgorithm, token jwt.Token) (string, error) { + if r == nil || token == nil { + return "", errors.New("telegram login ID token signer is unavailable") + } + key, ok := r.active[algorithm] + if !ok { + return "", fmt.Errorf("no active telegram login signing key for %s", algorithm) + } + signed, err := jwt.Sign(token, jwt.WithKey(key.jwaAlgorithm, key.private)) + if err != nil { + return "", fmt.Errorf("sign telegram login ID token with %s: %w", algorithm, err) + } + return string(signed), nil +} + +type IDTokenIssuerConfig struct { + Issuer string + TTL time.Duration + Now func() time.Time +} + +type IDTokenIssuer struct { + issuer string + ttl time.Duration + now func() time.Time + keys *SigningKeyRing +} + +func (i *IDTokenIssuer) Issuer() string { + if i == nil { + return "" + } + return i.issuer +} + +func (i *IDTokenIssuer) TTL() time.Duration { + if i == nil { + return 0 + } + return i.ttl +} + +func (i *IDTokenIssuer) SupportedAlgorithms() []string { + if i == nil { + return nil + } + return i.keys.SupportedAlgorithms() +} + +func (i *IDTokenIssuer) JWKS() ([]byte, string, error) { + if i == nil { + return nil, "", errors.New("telegram login ID token issuer is nil") + } + return i.keys.JWKS() +} + +func NewIDTokenIssuer(keys *SigningKeyRing, cfg IDTokenIssuerConfig) (*IDTokenIssuer, error) { + if keys == nil { + return nil, errors.New("telegram login signing key ring is required") + } + issuer, err := NormalizeWebOrigin(cfg.Issuer, true) + if err != nil { + return nil, fmt.Errorf("telegram login ID token issuer: %w", err) + } + if cfg.TTL == 0 { + cfg.TTL = defaultIDTokenTTL + } + if cfg.TTL < time.Minute || cfg.TTL > 24*time.Hour { + return nil, errors.New("telegram login ID token TTL is outside the bounded range") + } + if cfg.Now == nil { + cfg.Now = time.Now + } + return &IDTokenIssuer{issuer: issuer, ttl: cfg.TTL, now: cfg.Now, keys: keys}, nil +} + +func (i *IDTokenIssuer) Issue(request domain.TelegramLoginRequest) (string, error) { + if i == nil || request.Status != domain.TelegramLoginRequestApproved || request.AuthorizedUserID <= 0 || + request.ClientID == "" || request.ApprovedAt.IsZero() { + return "", domain.ErrTelegramLoginRequestInvalid + } + if err := domain.ValidateTelegramLoginScopes(request.Scopes, request.SigningAlgorithm); err != nil { + return "", err + } + identity := domain.TelegramLoginIdentitySnapshot{ + UserID: request.AuthorizedUserID, Name: request.ProfileName, GivenName: request.GivenName, + FamilyName: request.FamilyName, PreferredUsername: request.PreferredUsername, + Picture: request.Picture, PhoneNumber: request.PhoneNumber, + } + identity, err := identity.Sanitized(request.Requests(domain.TelegramLoginScopeProfile), request.PhoneShared) + if err != nil { + return "", err + } + now := i.now().UTC() + builder := jwt.NewBuilder(). + Issuer(i.issuer). + Audience([]string{request.ClientID}). + Subject(fmt.Sprintf("%d", identity.UserID)). + IssuedAt(now). + Expiration(now.Add(i.ttl)) + if request.Nonce != "" { + builder.Claim("nonce", request.Nonce) + } + if request.Requests(domain.TelegramLoginScopeProfile) { + builder.Claim("id", identity.UserID). + Claim("name", identity.Name). + Claim("given_name", identity.GivenName) + if identity.FamilyName != "" { + builder.Claim("family_name", identity.FamilyName) + } + if identity.PreferredUsername != "" { + builder.Claim("preferred_username", identity.PreferredUsername) + } + if identity.Picture != "" { + builder.Claim("picture", identity.Picture) + } + } + if request.Requests(domain.TelegramLoginScopePhone) && request.PhoneShared { + builder.Claim("phone_number", identity.PhoneNumber). + Claim("phone_number_verified", true) + } + token, err := builder.Build() + if err != nil { + return "", fmt.Errorf("build telegram login ID token: %w", err) + } + return i.keys.sign(request.SigningAlgorithm, token) +} diff --git a/internal/app/telegramlogin/jose_es256k_disabled.go b/internal/app/telegramlogin/jose_es256k_disabled.go new file mode 100644 index 00000000..870c3753 --- /dev/null +++ b/internal/app/telegramlogin/jose_es256k_disabled.go @@ -0,0 +1,5 @@ +//go:build !jwx_es256k + +package telegramlogin + +const telegramLoginES256KEnabled = false diff --git a/internal/app/telegramlogin/jose_es256k_disabled_test.go b/internal/app/telegramlogin/jose_es256k_disabled_test.go new file mode 100644 index 00000000..a13925b4 --- /dev/null +++ b/internal/app/telegramlogin/jose_es256k_disabled_test.go @@ -0,0 +1,17 @@ +//go:build !jwx_es256k + +package telegramlogin + +import ( + "testing" + + "telesrv/internal/domain" +) + +func TestES256KFailsClosedWithoutBuildTag(t *testing.T) { + if _, err := NewSigningKeyRing([]SigningKeyMaterial{{ + Algorithm: domain.TelegramLoginSigningES256K, PrivateKey: struct{}{}, Active: true, + }}, nil); err == nil { + t.Fatal("ES256K configuration unexpectedly accepted without jwx_es256k") + } +} diff --git a/internal/app/telegramlogin/jose_es256k_enabled.go b/internal/app/telegramlogin/jose_es256k_enabled.go new file mode 100644 index 00000000..601999a3 --- /dev/null +++ b/internal/app/telegramlogin/jose_es256k_enabled.go @@ -0,0 +1,5 @@ +//go:build jwx_es256k + +package telegramlogin + +const telegramLoginES256KEnabled = true diff --git a/internal/app/telegramlogin/jose_es256k_enabled_test.go b/internal/app/telegramlogin/jose_es256k_enabled_test.go new file mode 100644 index 00000000..de31551b --- /dev/null +++ b/internal/app/telegramlogin/jose_es256k_enabled_test.go @@ -0,0 +1,57 @@ +//go:build jwx_es256k + +package telegramlogin + +import ( + "testing" + "time" + + "github.com/decred/dcrd/dcrec/secp256k1/v4" + "github.com/lestrrat-go/jwx/v3/jwk" + "github.com/lestrrat-go/jwx/v3/jwt" + + "telesrv/internal/domain" +) + +func TestES256KIDTokenRoundTripWithBuildTag(t *testing.T) { + raw, err := secp256k1.GeneratePrivateKey() + if err != nil { + t.Fatal(err) + } + now := time.Date(2026, 7, 20, 10, 0, 0, 0, time.UTC) + ring, err := NewSigningKeyRing([]SigningKeyMaterial{{ + Algorithm: domain.TelegramLoginSigningES256K, + KeyID: "secp256k1-active", + PrivateKey: raw.ToECDSA(), + Active: true, + }}, func() time.Time { return now }) + if err != nil { + t.Fatal(err) + } + issuer, err := NewIDTokenIssuer(ring, IDTokenIssuerConfig{ + Issuer: "https://oauth.telesrv.test", Now: func() time.Time { return now }, + }) + if err != nil { + t.Fatal(err) + } + signed, err := issuer.Issue(domain.TelegramLoginRequest{ + ClientID: "9001", SigningAlgorithm: domain.TelegramLoginSigningES256K, + Scopes: []domain.TelegramLoginScope{domain.TelegramLoginScopeOpenID}, + Status: domain.TelegramLoginRequestApproved, AuthorizedUserID: 42, + ApprovedAt: now.Add(-time.Minute), + }) + if err != nil { + t.Fatal(err) + } + body, _, err := ring.JWKS() + if err != nil { + t.Fatal(err) + } + set, err := jwk.Parse(body) + if err != nil { + t.Fatal(err) + } + if _, err := jwt.Parse([]byte(signed), jwt.WithKeySet(set), jwt.WithValidate(false)); err != nil { + t.Fatalf("verify ES256K token: %v", err) + } +} diff --git a/internal/app/telegramlogin/jose_test.go b/internal/app/telegramlogin/jose_test.go new file mode 100644 index 00000000..108cd20a --- /dev/null +++ b/internal/app/telegramlogin/jose_test.go @@ -0,0 +1,188 @@ +package telegramlogin + +import ( + "crypto/ecdsa" + "crypto/ed25519" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "encoding/json" + "testing" + "time" + + "github.com/lestrrat-go/jwx/v3/jwk" + "github.com/lestrrat-go/jwx/v3/jwt" + + "telesrv/internal/domain" +) + +func telegramLoginTestSigningKeys(t *testing.T, now *time.Time) *SigningKeyRing { + t.Helper() + oldRSA, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + activeRSA, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + es256, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + _, ed25519Key, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + ring, err := NewSigningKeyRing([]SigningKeyMaterial{ + {Algorithm: domain.TelegramLoginSigningRS256, KeyID: "rsa-old", PrivateKey: oldRSA, PublishUntil: now.Add(2 * time.Hour)}, + {Algorithm: domain.TelegramLoginSigningRS256, KeyID: "rsa-active", PrivateKey: activeRSA, Active: true}, + {Algorithm: domain.TelegramLoginSigningES256, KeyID: "p256-active", PrivateKey: es256, Active: true}, + {Algorithm: domain.TelegramLoginSigningEdDSA, KeyID: "ed25519-active", PrivateKey: ed25519Key, Active: true}, + }, func() time.Time { return *now }) + if err != nil { + t.Fatal(err) + } + return ring +} + +func TestSigningKeyRingRotationAndAlgorithms(t *testing.T) { + now := time.Date(2026, 7, 20, 10, 0, 0, 0, time.UTC) + ring := telegramLoginTestSigningKeys(t, &now) + if got := ring.SupportedAlgorithms(); len(got) != 3 || got[0] != "RS256" || got[1] != "ES256" || got[2] != "EdDSA" { + t.Fatalf("SupportedAlgorithms = %#v", got) + } + body, etag, err := ring.JWKS() + if err != nil { + t.Fatal(err) + } + set, err := jwk.Parse(body) + if err != nil { + t.Fatalf("parse JWKS: %v", err) + } + if set.Len() != 4 || etag == "" { + t.Fatalf("JWKS len=%d etag=%q body=%s", set.Len(), etag, body) + } + var decoded map[string]any + if err := json.Unmarshal(body, &decoded); err != nil { + t.Fatal(err) + } + for i := 0; i < set.Len(); i++ { + key, _ := set.Key(i) + if key.Has("d") || key.Has("p") || key.Has("q") { + t.Fatalf("JWKS leaked private key material: %s", body) + } + } + now = now.Add(3 * time.Hour) + body, _, err = ring.JWKS() + if err != nil { + t.Fatal(err) + } + set, err = jwk.Parse(body) + if err != nil || set.Len() != 3 { + t.Fatalf("JWKS after retirement len=%d err=%v body=%s", set.Len(), err, body) + } +} + +func TestIDTokenIssuerScopeProjectionAndVerification(t *testing.T) { + now := time.Date(2026, 7, 20, 10, 0, 0, 0, time.UTC) + ring := telegramLoginTestSigningKeys(t, &now) + issuer, err := NewIDTokenIssuer(ring, IDTokenIssuerConfig{ + Issuer: "https://oauth.telesrv.test", Now: func() time.Time { return now }, + }) + if err != nil { + t.Fatal(err) + } + profileRequest := domain.TelegramLoginRequest{ + ClientID: "9001", SigningAlgorithm: domain.TelegramLoginSigningRS256, + Scopes: []domain.TelegramLoginScope{ + domain.TelegramLoginScopeOpenID, domain.TelegramLoginScopeProfile, domain.TelegramLoginScopePhone, + }, + Nonce: "request-nonce", Status: domain.TelegramLoginRequestApproved, AuthorizedUserID: 42, + ProfileName: "Alice Example", GivenName: "Alice", FamilyName: "Example", + PreferredUsername: "alice", Picture: "https://oauth.telesrv.test/userpic/42", + PhoneNumber: "15551234567", PhoneShared: true, ApprovedAt: now.Add(-time.Minute), + } + signed, err := issuer.Issue(profileRequest) + if err != nil { + t.Fatal(err) + } + jwksBody, _, err := ring.JWKS() + if err != nil { + t.Fatal(err) + } + set, err := jwk.Parse(jwksBody) + if err != nil { + t.Fatal(err) + } + token, err := jwt.Parse([]byte(signed), jwt.WithKeySet(set), jwt.WithValidate(false)) + if err != nil { + t.Fatalf("verify signed token: %v", err) + } + issuerValue, _ := token.Issuer() + subject, _ := token.Subject() + audience, _ := token.Audience() + if issuerValue != "https://oauth.telesrv.test" || subject != "42" || len(audience) != 1 || audience[0] != "9001" { + t.Fatalf("standard claims iss=%q sub=%q aud=%#v", issuerValue, subject, audience) + } + var id float64 + var name, phone, nonce string + var verified bool + if err := token.Get("id", &id); err != nil || id != 42 { + t.Fatalf("id claim=%v err=%v", id, err) + } + if err := token.Get("name", &name); err != nil || name != "Alice Example" { + t.Fatalf("name claim=%q err=%v", name, err) + } + if err := token.Get("phone_number", &phone); err != nil || phone != "15551234567" { + t.Fatalf("phone claim=%q err=%v", phone, err) + } + if err := token.Get("phone_number_verified", &verified); err != nil || !verified { + t.Fatalf("phone verified=%v err=%v", verified, err) + } + if err := token.Get("nonce", &nonce); err != nil || nonce != "request-nonce" { + t.Fatalf("nonce=%q err=%v", nonce, err) + } + + openidOnly := profileRequest + openidOnly.SigningAlgorithm = domain.TelegramLoginSigningEdDSA + openidOnly.Scopes = []domain.TelegramLoginScope{domain.TelegramLoginScopeOpenID} + openidOnly.ProfileName = "" + openidOnly.GivenName = "" + openidOnly.FamilyName = "" + openidOnly.PreferredUsername = "" + openidOnly.Picture = "" + openidOnly.PhoneNumber = "" + openidOnly.PhoneShared = false + signed, err = issuer.Issue(openidOnly) + if err != nil { + t.Fatal(err) + } + token, err = jwt.Parse([]byte(signed), jwt.WithKeySet(set), jwt.WithValidate(false)) + if err != nil { + t.Fatalf("verify EdDSA token: %v", err) + } + if token.Has("id") || token.Has("name") || token.Has("phone_number") { + t.Fatalf("openid-only token leaked optional claims: %#v", token.Keys()) + } +} + +func TestSigningKeyRingRejectsWrongCurveAndDuplicateActiveKey(t *testing.T) { + p384, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader) + if err != nil { + t.Fatal(err) + } + if _, err := NewSigningKeyRing([]SigningKeyMaterial{{ + Algorithm: domain.TelegramLoginSigningES256, PrivateKey: p384, Active: true, + }}, nil); err == nil { + t.Fatal("P-384 key unexpectedly accepted for ES256") + } + key1, _ := rsa.GenerateKey(rand.Reader, 2048) + key2, _ := rsa.GenerateKey(rand.Reader, 2048) + if _, err := NewSigningKeyRing([]SigningKeyMaterial{ + {Algorithm: domain.TelegramLoginSigningRS256, PrivateKey: key1, Active: true}, + {Algorithm: domain.TelegramLoginSigningRS256, PrivateKey: key2, Active: true}, + }, nil); err == nil { + t.Fatal("two active RS256 keys unexpectedly accepted") + } +} diff --git a/internal/app/telegramlogin/keyfiles.go b/internal/app/telegramlogin/keyfiles.go new file mode 100644 index 00000000..9d3023d5 --- /dev/null +++ b/internal/app/telegramlogin/keyfiles.go @@ -0,0 +1,186 @@ +package telegramlogin + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" + + "github.com/lestrrat-go/jwx/v3/jwk" + + "telesrv/internal/domain" +) + +const ( + maxTelegramLoginManifestBytes = 1 << 20 + maxTelegramLoginKeyBytes = 256 << 10 +) + +type signingKeyManifest struct { + Version int `json:"version"` + Keys []signingKeyManifestEntry `json:"keys"` +} + +type signingKeyManifestEntry struct { + Algorithm domain.TelegramLoginSigningAlgorithm `json:"algorithm"` + KeyID string `json:"kid,omitempty"` + PrivateKeyFile string `json:"private_key_file"` + Active bool `json:"active"` + PublishUntil string `json:"publish_until,omitempty"` +} + +// LoadSigningKeyRing reads a versioned manifest and private PEM/JWK files. +// Relative key paths are resolved against the manifest directory. The caller +// should atomically replace files and rebuild/swap the ring when rotating. +func LoadSigningKeyRing(path string, now func() time.Time) (*SigningKeyRing, error) { + var manifest signingKeyManifest + if err := readStrictJSONFile(path, maxTelegramLoginManifestBytes, &manifest); err != nil { + return nil, fmt.Errorf("load telegram login signing manifest: %w", err) + } + if manifest.Version != 1 || len(manifest.Keys) == 0 || len(manifest.Keys) > 32 { + return nil, errors.New("telegram login signing manifest has invalid version or key count") + } + baseDir := filepath.Dir(path) + materials := make([]SigningKeyMaterial, 0, len(manifest.Keys)) + for index, entry := range manifest.Keys { + keyPath := strings.TrimSpace(entry.PrivateKeyFile) + if !entry.Algorithm.Valid() || keyPath == "" { + return nil, fmt.Errorf("telegram login signing manifest key %d is invalid", index) + } + if !filepath.IsAbs(keyPath) { + keyPath = filepath.Join(baseDir, keyPath) + } + data, err := readBoundedFile(keyPath, maxTelegramLoginKeyBytes) + if err != nil { + return nil, fmt.Errorf("read telegram login signing key %d: %w", index, err) + } + var parsed jwk.Key + if len(bytes.TrimSpace(data)) > 0 && bytes.TrimSpace(data)[0] == '{' { + parsed, err = jwk.ParseKey(data) + } else { + parsed, err = jwk.ParseKey(data, jwk.WithPEM(true)) + } + if err != nil { + return nil, fmt.Errorf("parse telegram login signing key %d: %w", index, err) + } + var raw any + if err := jwk.Export(parsed, &raw); err != nil { + return nil, fmt.Errorf("export telegram login signing key %d: %w", index, err) + } + var publishUntil time.Time + if entry.PublishUntil != "" { + publishUntil, err = time.Parse(time.RFC3339, entry.PublishUntil) + if err != nil { + return nil, fmt.Errorf("parse telegram login signing key %d publish_until: %w", index, err) + } + } + if entry.Active && !publishUntil.IsZero() { + return nil, fmt.Errorf("active telegram login signing key %d must not set publish_until", index) + } + if !entry.Active && publishUntil.IsZero() { + return nil, fmt.Errorf("retiring telegram login signing key %d requires publish_until", index) + } + materials = append(materials, SigningKeyMaterial{ + Algorithm: entry.Algorithm, KeyID: entry.KeyID, PrivateKey: raw, + Active: entry.Active, PublishUntil: publishUntil, + }) + } + return NewSigningKeyRing(materials, now) +} + +type codeKeyManifest struct { + Version int `json:"version"` + Active string `json:"active"` + Keys map[string]string `json:"keys"` +} + +func LoadCodeSealer(path string) (*CodeSealer, error) { + var manifest codeKeyManifest + if err := readStrictJSONFile(path, maxTelegramLoginManifestBytes, &manifest); err != nil { + return nil, fmt.Errorf("load telegram login code-key manifest: %w", err) + } + if manifest.Version != 1 || manifest.Active == "" || len(manifest.Keys) == 0 || len(manifest.Keys) > 16 { + return nil, errors.New("telegram login code-key manifest has invalid version or key count") + } + keys := make(map[string][]byte, len(manifest.Keys)) + for keyID, encoded := range manifest.Keys { + if strings.TrimSpace(keyID) == "" || keyID != strings.TrimSpace(keyID) || len(keyID) > 128 { + return nil, errors.New("telegram login code-key manifest has invalid key id") + } + raw, err := decodeBase64Key(encoded) + if err != nil || len(raw) != 32 { + return nil, fmt.Errorf("telegram login code-key %q must be 32 base64-encoded bytes", keyID) + } + keys[keyID] = raw + } + return NewCodeSealer(manifest.Active, keys) +} + +func LoadClientSecretPepper(path string) ([]byte, error) { + data, err := readBoundedFile(path, 4096) + if err != nil { + return nil, fmt.Errorf("read telegram login client-secret pepper: %w", err) + } + raw, err := decodeBase64Key(strings.TrimSpace(string(data))) + if err != nil || len(raw) != 32 { + return nil, errors.New("telegram login client-secret pepper must be 32 base64-encoded bytes") + } + return raw, nil +} + +func decodeBase64Key(value string) ([]byte, error) { + value = strings.TrimSpace(value) + for _, encoding := range []*base64.Encoding{ + base64.RawURLEncoding, base64.URLEncoding, base64.RawStdEncoding, base64.StdEncoding, + } { + if raw, err := encoding.DecodeString(value); err == nil { + return raw, nil + } + } + return nil, errors.New("invalid base64") +} + +func readStrictJSONFile(path string, maxBytes int64, target any) error { + data, err := readBoundedFile(path, maxBytes) + if err != nil { + return err + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(target); err != nil { + return err + } + var extra any + if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) { + if err == nil { + return errors.New("multiple JSON values") + } + return err + } + return nil +} + +func readBoundedFile(path string, maxBytes int64) ([]byte, error) { + if strings.TrimSpace(path) == "" || maxBytes <= 0 { + return nil, errors.New("invalid file path or size bound") + } + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer func() { _ = file.Close() }() + info, err := file.Stat() + if err != nil { + return nil, err + } + if !info.Mode().IsRegular() || info.Size() > maxBytes { + return nil, errors.New("file is not regular or exceeds size bound") + } + return io.ReadAll(io.LimitReader(file, maxBytes+1)) +} diff --git a/internal/app/telegramlogin/keyfiles_test.go b/internal/app/telegramlogin/keyfiles_test.go new file mode 100644 index 00000000..8c785deb --- /dev/null +++ b/internal/app/telegramlogin/keyfiles_test.go @@ -0,0 +1,86 @@ +package telegramlogin + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "os" + "path/filepath" + "testing" + "time" +) + +func TestLoadSigningKeyRingAndSymmetricKeyFiles(t *testing.T) { + dir := t.TempDir() + rsaKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + pemBytes := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(rsaKey)}) + if err := os.WriteFile(filepath.Join(dir, "rsa.pem"), pemBytes, 0o600); err != nil { + t.Fatal(err) + } + manifest := map[string]any{ + "version": 1, + "keys": []map[string]any{{ + "algorithm": "RS256", "kid": "rsa-test", "private_key_file": "rsa.pem", "active": true, + }}, + } + manifestBytes, _ := json.Marshal(manifest) + manifestPath := filepath.Join(dir, "signing.json") + if err := os.WriteFile(manifestPath, manifestBytes, 0o600); err != nil { + t.Fatal(err) + } + ring, err := LoadSigningKeyRing(manifestPath, nil) + if err != nil { + t.Fatal(err) + } + if got := ring.SupportedAlgorithms(); len(got) != 1 || got[0] != "RS256" { + t.Fatalf("algorithms=%#v", got) + } + + codeKey := make([]byte, 32) + pepper := make([]byte, 32) + _, _ = rand.Read(codeKey) + _, _ = rand.Read(pepper) + codeManifest, _ := json.Marshal(map[string]any{ + "version": 1, "active": "2026-07", "keys": map[string]string{"2026-07": base64.RawURLEncoding.EncodeToString(codeKey)}, + }) + codePath := filepath.Join(dir, "code-keys.json") + pepperPath := filepath.Join(dir, "pepper") + if err := os.WriteFile(codePath, codeManifest, 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(pepperPath, []byte(base64.RawURLEncoding.EncodeToString(pepper)), 0o600); err != nil { + t.Fatal(err) + } + sealer, err := LoadCodeSealer(codePath) + if err != nil { + t.Fatal(err) + } + sealed, nonce, kid, err := sealer.Seal("code", []byte("aad")) + if err != nil { + t.Fatal(err) + } + if opened, err := sealer.Open(sealed, nonce, kid, []byte("aad")); err != nil || opened != "code" { + t.Fatalf("open=%q err=%v", opened, err) + } + loadedPepper, err := LoadClientSecretPepper(pepperPath) + if err != nil || string(loadedPepper) != string(pepper) { + t.Fatalf("pepper len=%d err=%v", len(loadedPepper), err) + } +} + +func TestLoadSigningKeyRingRejectsUnknownManifestFieldAndUnboundedRetiringKey(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "bad.json") + if err := os.WriteFile(path, []byte(`{"version":1,"keys":[],"unknown":true}`), 0o600); err != nil { + t.Fatal(err) + } + if _, err := LoadSigningKeyRing(path, func() time.Time { return time.Now() }); err == nil { + t.Fatal("unknown manifest field unexpectedly accepted") + } +} diff --git a/internal/app/telegramlogin/native.go b/internal/app/telegramlogin/native.go new file mode 100644 index 00000000..97d74282 --- /dev/null +++ b/internal/app/telegramlogin/native.go @@ -0,0 +1,80 @@ +package telegramlogin + +import ( + "net/url" + "regexp" + "strings" + "unicode" + + "telesrv/internal/domain" +) + +var nativeApplicationIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{2,254}$`) + +func normalizeNativeApplicationID(raw string) (string, error) { + raw = strings.TrimSpace(raw) + if !nativeApplicationIDPattern.MatchString(raw) || !strings.Contains(raw, ".") || strings.Contains(raw, "..") { + return "", domain.ErrTelegramLoginClientInvalid + } + return raw, nil +} + +func normalizeNativeVerificationID(platform domain.TelegramLoginNativePlatform, raw string) (string, error) { + raw = strings.ToUpper(strings.TrimSpace(raw)) + switch platform { + case domain.TelegramLoginNativeIOS: + if len(raw) != 10 || strings.IndexFunc(raw, func(r rune) bool { + return !((r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9')) + }) >= 0 { + return "", domain.ErrTelegramLoginClientInvalid + } + case domain.TelegramLoginNativeAndroid: + raw = strings.ReplaceAll(raw, ":", "") + if len(raw) != 64 || strings.IndexFunc(raw, func(r rune) bool { + return !((r >= 'A' && r <= 'F') || (r >= '0' && r <= '9')) + }) >= 0 { + return "", domain.ErrTelegramLoginClientInvalid + } + default: + return "", domain.ErrTelegramLoginClientInvalid + } + return raw, nil +} + +// NormalizeNativeCallbackURI accepts the exact HTTPS universal/app link or a +// non-web custom scheme registered for a native application. Query and +// fragment components are forbidden because OAuth response fields are +// appended by the provider and must not collide with application input. +func NormalizeNativeCallbackURI(raw string, allowLoopbackHTTP bool) (string, error) { + if raw == "" || len(raw) > maxTelegramLoginURLLength || raw != strings.TrimSpace(raw) || strings.IndexFunc(raw, unicode.IsControl) >= 0 { + return "", domain.ErrTelegramLoginURLInvalid + } + u, err := url.Parse(raw) + if err != nil || !u.IsAbs() || u.Opaque != "" || u.User != nil || u.Host == "" || u.RawQuery != "" || u.Fragment != "" || u.RawPath != "" { + return "", domain.ErrTelegramLoginURLInvalid + } + if strings.EqualFold(u.Scheme, "http") || strings.EqualFold(u.Scheme, "https") { + normalized, _, err := NormalizeRedirectURI(raw, allowLoopbackHTTP) + return normalized, err + } + scheme := strings.ToLower(u.Scheme) + if !validAppScheme(scheme) || scheme == "tg" || scheme == "javascript" || scheme == "data" || scheme == "file" || u.Port() != "" { + return "", domain.ErrTelegramLoginURLInvalid + } + host := strings.ToLower(strings.TrimSuffix(u.Hostname(), ".")) + if host == "" || strings.IndexFunc(host, func(r rune) bool { + return !((r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '.') + }) >= 0 { + return "", domain.ErrTelegramLoginURLInvalid + } + u.Scheme, u.Host = scheme, host + return u.String(), nil +} + +func normalizeNativeDisplayName(raw string) (string, error) { + raw = strings.TrimSpace(raw) + if raw == "" || len(raw) > 128 || strings.IndexFunc(raw, unicode.IsControl) >= 0 { + return "", domain.ErrTelegramLoginClientInvalid + } + return raw, nil +} diff --git a/internal/app/telegramlogin/service.go b/internal/app/telegramlogin/service.go new file mode 100644 index 00000000..77c34f97 --- /dev/null +++ b/internal/app/telegramlogin/service.go @@ -0,0 +1,1374 @@ +package telegramlogin + +import ( + "context" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/binary" + "encoding/hex" + "errors" + "fmt" + "net/url" + "slices" + "sort" + "strconv" + "strings" + "time" + "unicode/utf8" + + "telesrv/internal/domain" + "telesrv/internal/store" +) + +const ( + defaultRequestTTL = 5 * time.Minute + defaultCodeTTL = 2 * time.Minute +) + +var telegramLoginMatchCodePool = []string{ + "🍏", "🍊", "🍋", "🍇", "🍉", "🍒", "🥝", "🥕", + "🚗", "🚲", "✈️", "🚀", "⛵", "🏠", "🏰", "⛺", + "⚽", "🏀", "🎾", "🎲", "🎸", "🎹", "📷", "💡", +} + +type Config struct { + Issuer string + AppScheme string + AllowLoopbackHTTP bool + ClientSecretPepper []byte + SupportedSigningAlgorithms []domain.TelegramLoginSigningAlgorithm + RequestTTL time.Duration + CodeTTL time.Duration + Now func() time.Time +} + +type Service struct { + store store.TelegramLoginStore + sealer *CodeSealer + issuer string + appScheme string + allowLoopbackHTTP bool + clientSecretPepper []byte + signingAlgorithms []domain.TelegramLoginSigningAlgorithm + signingAlgorithmSet map[domain.TelegramLoginSigningAlgorithm]struct{} + requestTTL time.Duration + codeTTL time.Duration + now func() time.Time +} + +func NewService(loginStore store.TelegramLoginStore, sealer *CodeSealer, cfg Config) (*Service, error) { + if loginStore == nil || sealer == nil || len(cfg.ClientSecretPepper) < 32 { + return nil, fmt.Errorf("telegram login dependencies are incomplete") + } + issuer, err := NormalizeWebOrigin(cfg.Issuer, cfg.AllowLoopbackHTTP) + if err != nil { + return nil, fmt.Errorf("telegram login issuer: %w", err) + } + if !validAppScheme(cfg.AppScheme) { + return nil, fmt.Errorf("telegram login app scheme is invalid") + } + if cfg.RequestTTL == 0 { + cfg.RequestTTL = defaultRequestTTL + } + if cfg.CodeTTL == 0 { + cfg.CodeTTL = defaultCodeTTL + } + if cfg.RequestTTL < time.Minute || cfg.RequestTTL > 15*time.Minute || cfg.CodeTTL < 30*time.Second || cfg.CodeTTL > 10*time.Minute { + return nil, fmt.Errorf("telegram login ttl is outside the bounded range") + } + if cfg.Now == nil { + cfg.Now = time.Now + } + var signingAlgorithmSet map[domain.TelegramLoginSigningAlgorithm]struct{} + if len(cfg.SupportedSigningAlgorithms) > 0 { + signingAlgorithmSet = make(map[domain.TelegramLoginSigningAlgorithm]struct{}, len(cfg.SupportedSigningAlgorithms)) + for _, algorithm := range cfg.SupportedSigningAlgorithms { + if !algorithm.Valid() { + return nil, fmt.Errorf("telegram login supported signing algorithm is invalid") + } + signingAlgorithmSet[algorithm] = struct{}{} + } + } + return &Service{ + store: loginStore, sealer: sealer, issuer: issuer, appScheme: strings.ToLower(cfg.AppScheme), + allowLoopbackHTTP: cfg.AllowLoopbackHTTP, + clientSecretPepper: append([]byte(nil), cfg.ClientSecretPepper...), + signingAlgorithms: append([]domain.TelegramLoginSigningAlgorithm(nil), cfg.SupportedSigningAlgorithms...), + signingAlgorithmSet: signingAlgorithmSet, + requestTTL: cfg.RequestTTL, codeTTL: cfg.CodeTTL, now: cfg.Now, + }, nil +} + +func (s *Service) signingAlgorithmSupported(algorithm domain.TelegramLoginSigningAlgorithm) bool { + if !algorithm.Valid() { + return false + } + if s.signingAlgorithmSet == nil { + return true + } + _, ok := s.signingAlgorithmSet[algorithm] + return ok +} + +func (s *Service) defaultSigningAlgorithm() (domain.TelegramLoginSigningAlgorithm, bool) { + if s.signingAlgorithmSupported(domain.TelegramLoginSigningRS256) { + return domain.TelegramLoginSigningRS256, true + } + if len(s.signingAlgorithms) > 0 { + return s.signingAlgorithms[0], true + } + return "", false +} + +func validAppScheme(value string) bool { + if value == "" || strings.ToLower(value) != value { + return false + } + for i, r := range value { + if (r >= 'a' && r <= 'z') || (i > 0 && r >= '0' && r <= '9') || (i > 0 && (r == '+' || r == '-' || r == '.')) { + continue + } + return false + } + return true +} + +type ClientCredentials struct { + Client domain.TelegramLoginClient + Secret string +} + +type ClientConfiguration struct { + Client domain.TelegramLoginClient + AllowedURLs []domain.TelegramLoginAllowedURL + NativeApps []domain.TelegramLoginNativeApp +} + +// EnsureClient creates the bot's OIDC client once. Concurrent BotFather +// sessions converge on the durable winner; only the creator receives the +// one-time secret. +func (s *Service) EnsureClient(ctx context.Context, botUserID int64) (ClientCredentials, bool, error) { + if client, found, err := s.store.GetTelegramLoginClientByBot(ctx, botUserID); err != nil { + return ClientCredentials{}, false, err + } else if found { + return ClientCredentials{Client: client}, false, nil + } + algorithm, ok := s.defaultSigningAlgorithm() + if !ok { + return ClientCredentials{}, false, domain.ErrTelegramLoginClientInvalid + } + created, err := s.CreateClient(ctx, botUserID, algorithm) + if err == nil { + return created, true, nil + } + if !errors.Is(err, domain.ErrTelegramLoginRequestConflict) { + return ClientCredentials{}, false, err + } + client, found, readErr := s.store.GetTelegramLoginClientByBot(ctx, botUserID) + if readErr != nil { + return ClientCredentials{}, false, readErr + } + if !found { + return ClientCredentials{}, false, err + } + return ClientCredentials{Client: client}, false, nil +} + +func (s *Service) ClientConfiguration(ctx context.Context, botUserID int64) (ClientConfiguration, bool, error) { + client, found, err := s.store.GetTelegramLoginClientByBot(ctx, botUserID) + if err != nil || !found { + return ClientConfiguration{}, found, err + } + allowed, err := s.store.ListTelegramLoginAllowedURLs(ctx, botUserID) + if err != nil { + return ClientConfiguration{}, false, err + } + apps, err := s.store.ListTelegramLoginNativeApps(ctx, botUserID) + if err != nil { + return ClientConfiguration{}, false, err + } + return ClientConfiguration{Client: client, AllowedURLs: allowed, NativeApps: apps}, true, nil +} + +func (s *Service) CreateClient(ctx context.Context, botUserID int64, algorithm domain.TelegramLoginSigningAlgorithm) (ClientCredentials, error) { + if botUserID <= 0 || !s.signingAlgorithmSupported(algorithm) { + return ClientCredentials{}, domain.ErrTelegramLoginClientInvalid + } + now := s.now().UTC() + return s.createClientWithSecret(ctx, domain.TelegramLoginClient{ + BotUserID: botUserID, ClientID: strconv.FormatInt(botUserID, 10), + SecretVersion: 1, SigningAlgorithm: algorithm, Enabled: true, + CreatedAt: now, UpdatedAt: now, + }) +} + +func (s *Service) RotateClientSecret(ctx context.Context, botUserID int64) (ClientCredentials, error) { + client, found, err := s.store.GetTelegramLoginClientByBot(ctx, botUserID) + if err != nil { + return ClientCredentials{}, err + } + if !found { + return ClientCredentials{}, domain.ErrTelegramLoginClientInvalid + } + secret, err := GenerateOpaqueToken() + if err != nil { + return ClientCredentials{}, err + } + secretHash, err := HashClientSecret(s.clientSecretPepper, secret) + if err != nil { + return ClientCredentials{}, err + } + client, err = s.store.RotateTelegramLoginClientSecret(ctx, botUserID, client.SecretVersion, secretHash, s.now().UTC()) + if err != nil { + return ClientCredentials{}, err + } + return ClientCredentials{Client: client, Secret: secret}, nil +} + +func (s *Service) createClientWithSecret(ctx context.Context, client domain.TelegramLoginClient) (ClientCredentials, error) { + secret, err := GenerateOpaqueToken() + if err != nil { + return ClientCredentials{}, err + } + client.SecretHash, err = HashClientSecret(s.clientSecretPepper, secret) + if err != nil { + return ClientCredentials{}, err + } + client, err = s.store.CreateTelegramLoginClient(ctx, client) + if err != nil { + return ClientCredentials{}, err + } + return ClientCredentials{Client: client, Secret: secret}, nil +} + +func (s *Service) AddAllowedURL(ctx context.Context, botUserID int64, kind domain.TelegramLoginAllowedURLKind, raw string) (domain.TelegramLoginAllowedURL, error) { + var normalized string + var err error + switch kind { + case domain.TelegramLoginAllowedWebOrigin: + normalized, err = NormalizeWebOrigin(raw, s.allowLoopbackHTTP) + case domain.TelegramLoginAllowedRedirectURI: + normalized, _, err = NormalizeRedirectURI(raw, s.allowLoopbackHTTP) + default: + err = domain.ErrTelegramLoginURLInvalid + } + if err != nil { + return domain.TelegramLoginAllowedURL{}, err + } + return s.store.AddTelegramLoginAllowedURL(ctx, domain.TelegramLoginAllowedURL{ + BotUserID: botUserID, Kind: kind, NormalizedURL: normalized, CreatedAt: s.now().UTC(), + }) +} + +func (s *Service) DeleteAllowedURL(ctx context.Context, botUserID int64, kind domain.TelegramLoginAllowedURLKind, raw string) (bool, error) { + var normalized string + var err error + switch kind { + case domain.TelegramLoginAllowedWebOrigin: + normalized, err = NormalizeWebOrigin(raw, s.allowLoopbackHTTP) + case domain.TelegramLoginAllowedRedirectURI: + normalized, _, err = NormalizeRedirectURI(raw, s.allowLoopbackHTTP) + default: + err = domain.ErrTelegramLoginURLInvalid + } + if err != nil { + return false, err + } + return s.store.DeleteTelegramLoginAllowedURL(ctx, botUserID, kind, normalized) +} + +func (s *Service) SetClientEnabled(ctx context.Context, botUserID int64, enabled bool) error { + if enabled { + client, found, err := s.store.GetTelegramLoginClientByBot(ctx, botUserID) + if err != nil { + return err + } + if !found || !s.signingAlgorithmSupported(client.SigningAlgorithm) { + return domain.ErrTelegramLoginClientInvalid + } + } + return s.store.SetTelegramLoginClientEnabled(ctx, botUserID, enabled, s.now().UTC()) +} + +func (s *Service) SetClientSigningAlgorithm(ctx context.Context, botUserID int64, algorithm domain.TelegramLoginSigningAlgorithm) (domain.TelegramLoginClient, error) { + if botUserID <= 0 || !s.signingAlgorithmSupported(algorithm) { + return domain.TelegramLoginClient{}, domain.ErrTelegramLoginClientInvalid + } + return s.store.SetTelegramLoginClientSigningAlgorithm(ctx, botUserID, algorithm, s.now().UTC()) +} + +func (s *Service) AddNativeApp(ctx context.Context, botUserID int64, platform domain.TelegramLoginNativePlatform, applicationID, verificationID, callbackURI, displayName string) (domain.TelegramLoginNativeApp, error) { + if botUserID <= 0 || !platform.Valid() { + return domain.TelegramLoginNativeApp{}, domain.ErrTelegramLoginClientInvalid + } + applicationID, err := normalizeNativeApplicationID(applicationID) + if err != nil { + return domain.TelegramLoginNativeApp{}, err + } + verificationID, err = normalizeNativeVerificationID(platform, verificationID) + if err != nil { + return domain.TelegramLoginNativeApp{}, err + } + callbackURI, err = NormalizeNativeCallbackURI(callbackURI, s.allowLoopbackHTTP) + if err != nil { + return domain.TelegramLoginNativeApp{}, err + } + displayName, err = normalizeNativeDisplayName(displayName) + if err != nil { + return domain.TelegramLoginNativeApp{}, err + } + now := s.now().UTC() + return s.store.UpsertTelegramLoginNativeApp(ctx, domain.TelegramLoginNativeApp{ + BotUserID: botUserID, Platform: platform, ApplicationID: applicationID, + VerificationID: verificationID, CallbackURI: callbackURI, VerifiedDisplayName: displayName, + Enabled: true, CreatedAt: now, UpdatedAt: now, + }) +} + +func (s *Service) DeleteNativeApp(ctx context.Context, botUserID, appID int64) (bool, error) { + if botUserID <= 0 || appID <= 0 { + return false, domain.ErrTelegramLoginClientInvalid + } + return s.store.DeleteTelegramLoginNativeApp(ctx, botUserID, appID) +} + +func (s *Service) matchNativeApp(ctx context.Context, botUserID int64, platform domain.TelegramLoginNativePlatform, rawCallbackURI string) (domain.TelegramLoginNativeApp, string, bool, error) { + callbackURI, err := NormalizeNativeCallbackURI(rawCallbackURI, s.allowLoopbackHTTP) + if err != nil { + return domain.TelegramLoginNativeApp{}, "", false, nil + } + apps, err := s.store.ListTelegramLoginNativeApps(ctx, botUserID) + if err != nil { + return domain.TelegramLoginNativeApp{}, "", false, err + } + for _, app := range apps { + if app.Enabled && app.CallbackURI == callbackURI && (!platform.Valid() || app.Platform == platform) { + return app, callbackURI, true, nil + } + } + return domain.TelegramLoginNativeApp{}, callbackURI, false, nil +} + +// ValidateMessageButton verifies the linked-domain invariant for a legacy +// Bot API login_url button. Bot buttons are origin-bound (not exact callback +// URI-bound): the final signed user fields are appended to the original URL. +func (s *Service) ValidateMessageButton(ctx context.Context, botUserID int64, rawURL string) (normalizedURL, domainName string, err error) { + client, found, err := s.store.GetTelegramLoginClientByBot(ctx, botUserID) + if err != nil { + return "", "", err + } + if !found || !client.Enabled { + return "", "", domain.ErrTelegramLoginClientDisabled + } + normalizedURL, domainName, err = NormalizeRedirectURI(rawURL, s.allowLoopbackHTTP) + if err != nil { + return "", "", err + } + u, err := url.Parse(normalizedURL) + if err != nil { + return "", "", domain.ErrTelegramLoginURLInvalid + } + origin, err := NormalizeWebOrigin(u.Scheme+"://"+u.Host, s.allowLoopbackHTTP) + if err != nil { + return "", "", err + } + allowed, err := s.store.IsTelegramLoginURLAllowed(ctx, botUserID, domain.TelegramLoginAllowedWebOrigin, origin) + if err != nil { + return "", "", err + } + if !allowed { + return "", "", domain.ErrTelegramLoginOriginNotAllowed + } + return normalizedURL, domainName, nil +} + +// AuthorizeMessageButton implements the legacy Seamless Login button path. +// It creates a short-lived internal request and immediately performs the same +// transactional approval used by OIDC, so web_authorizations and optional bot +// write access cannot diverge. The returned URL uses Telegram's documented +// legacy HMAC format and is independent from authorization-code/PKCE tokens. +func (s *Service) AuthorizeMessageButton(ctx context.Context, params domain.TelegramLoginMessageButtonAuthorization) (domain.TelegramLoginMessageButtonResult, error) { + if params.UserID <= 0 || params.BotUserID <= 0 || params.Identity.UserID != params.UserID || + params.MessageID <= 0 || params.ButtonID < 0 || params.Peer.ID <= 0 || + (params.Peer.Type != domain.PeerTypeUser && params.Peer.Type != domain.PeerTypeChannel) || + params.WriteAllowed && !params.RequestWriteAccess { + return domain.TelegramLoginMessageButtonResult{}, domain.ErrTelegramLoginRequestInvalid + } + tokenBotID, _, ok := domain.ParseBotToken(params.BotToken) + if !ok || tokenBotID != params.BotUserID { + return domain.TelegramLoginMessageButtonResult{}, domain.ErrTelegramLoginSecretInvalid + } + normalizedURL, domainName, err := s.ValidateMessageButton(ctx, params.BotUserID, params.URL) + if err != nil { + return domain.TelegramLoginMessageButtonResult{}, err + } + client, found, err := s.store.GetTelegramLoginClientByBot(ctx, params.BotUserID) + if err != nil { + return domain.TelegramLoginMessageButtonResult{}, err + } + if !found || !client.Enabled { + return domain.TelegramLoginMessageButtonResult{}, domain.ErrTelegramLoginClientDisabled + } + u, err := url.Parse(normalizedURL) + if err != nil { + return domain.TelegramLoginMessageButtonResult{}, domain.ErrTelegramLoginURLInvalid + } + origin, err := NormalizeWebOrigin(u.Scheme+"://"+u.Host, s.allowLoopbackHTTP) + if err != nil { + return domain.TelegramLoginMessageButtonResult{}, err + } + requestToken, err := GenerateOpaqueToken() + if err != nil { + return domain.TelegramLoginMessageButtonResult{}, err + } + browserToken, err := GenerateOpaqueToken() + if err != nil { + return domain.TelegramLoginMessageButtonResult{}, err + } + scopes := []domain.TelegramLoginScope{domain.TelegramLoginScopeOpenID, domain.TelegramLoginScopeProfile} + if params.RequestWriteAccess { + scopes = append(scopes, domain.TelegramLoginScopeBotAccess) + } + now := s.now().UTC() + request, err := s.store.CreateTelegramLoginRequest(ctx, domain.TelegramLoginRequest{ + RequestTokenHash: HashOpaqueToken(requestToken), BrowserTokenHash: HashOpaqueToken(browserToken), + BotUserID: params.BotUserID, ClientID: client.ClientID, SigningAlgorithm: client.SigningAlgorithm, + Source: domain.TelegramLoginRequestMessageButton, ResponseType: "legacy_url", + RedirectURI: normalizedURL, Origin: origin, Domain: domainName, Scopes: scopes, + Browser: boundedValue(params.Browser, "Telegram", 255), Platform: boundedValue(params.Platform, "Telegram Client", 255), + IP: boundedValue(params.IP, "Unknown IP", 128), Region: boundedValue(params.Region, "Unknown region", 255), + UserIDHint: params.UserID, PeerType: params.Peer.Type, PeerID: params.Peer.ID, + MessageID: params.MessageID, ButtonID: params.ButtonID, + Status: domain.TelegramLoginRequestPending, CreatedAt: now, ExpiresAt: now.Add(s.requestTTL), + }) + if err != nil { + return domain.TelegramLoginMessageButtonResult{}, err + } + hash, err := randomWebAuthorizationHash() + if err != nil { + return domain.TelegramLoginMessageButtonResult{}, err + } + approved, webAuthorization, err := s.store.ApproveTelegramLoginRequest(ctx, domain.TelegramLoginApproval{ + RequestID: request.ID, Identity: params.Identity, WriteAllowed: params.WriteAllowed, ApprovedAt: now, + }, hash) + if err != nil { + return domain.TelegramLoginMessageButtonResult{}, err + } + acceptedURL, err := appendLegacyTelegramLoginResult(normalizedURL, approved, params.BotToken) + if err != nil { + return domain.TelegramLoginMessageButtonResult{}, err + } + return domain.TelegramLoginMessageButtonResult{URL: acceptedURL, Request: approved, WebAuthorization: webAuthorization}, nil +} + +func appendLegacyTelegramLoginResult(rawURL string, request domain.TelegramLoginRequest, botToken string) (string, error) { + u, err := url.Parse(rawURL) + if err != nil || !u.IsAbs() || request.Status != domain.TelegramLoginRequestApproved || request.AuthorizedUserID <= 0 || request.ApprovedAt.IsZero() { + return "", domain.ErrTelegramLoginRequestInvalid + } + values := map[string]string{ + "auth_date": strconv.FormatInt(request.ApprovedAt.Unix(), 10), + "first_name": request.GivenName, + "id": strconv.FormatInt(request.AuthorizedUserID, 10), + } + if request.FamilyName != "" { + values["last_name"] = request.FamilyName + } + if request.PreferredUsername != "" { + values["username"] = request.PreferredUsername + } + if request.Picture != "" { + values["photo_url"] = request.Picture + } + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + sort.Strings(keys) + check := make([]string, 0, len(keys)) + for _, key := range keys { + check = append(check, key+"="+values[key]) + } + secret := sha256.Sum256([]byte(botToken)) + mac := hmac.New(sha256.New, secret[:]) + _, _ = mac.Write([]byte(strings.Join(check, "\n"))) + query := u.Query() + for key, value := range values { + query.Set(key, value) + } + query.Set("hash", hex.EncodeToString(mac.Sum(nil))) + u.RawQuery = query.Encode() + return u.String(), nil +} + +type CreateAuthorizationParams struct { + ClientID string + RedirectURI string + ResponseType string + Scope string + State string + Nonce string + CodeChallenge string + CodeChallengeMethod string + Origin string + InAppOrigin string + Source domain.TelegramLoginRequestSource + Browser string + Platform string + IP string + Region string + UserIDHint int64 + NativePlatform domain.TelegramLoginNativePlatform + IncludeMatchCodes bool + MatchCodesFirst bool +} + +type CreatedAuthorization struct { + Request domain.TelegramLoginRequest + RequestToken string + BrowserToken string + DeepLink string +} + +func (s *Service) CreateAuthorization(ctx context.Context, params CreateAuthorizationParams) (CreatedAuthorization, error) { + client, found, err := s.store.GetTelegramLoginClient(ctx, params.ClientID) + if err != nil { + return CreatedAuthorization{}, err + } + if !found || !client.Enabled { + return CreatedAuthorization{}, domain.ErrTelegramLoginClientDisabled + } + if !s.signingAlgorithmSupported(client.SigningAlgorithm) { + return CreatedAuthorization{}, domain.ErrTelegramLoginClientDisabled + } + if params.ResponseType != "code" && params.ResponseType != "post_message" { + return CreatedAuthorization{}, domain.ErrTelegramLoginRequestInvalid + } + source := params.Source + if source == "" { + source = domain.TelegramLoginRequestWeb + } + var allowed, isApp bool + var nativeApp domain.TelegramLoginNativeApp + redirectURI, domainName, redirectErr := NormalizeRedirectURI(params.RedirectURI, s.allowLoopbackHTTP) + if params.ResponseType == "code" && redirectErr == nil && !params.NativePlatform.Valid() { + allowed, err = s.store.IsTelegramLoginURLAllowed(ctx, client.BotUserID, domain.TelegramLoginAllowedRedirectURI, redirectURI) + if err != nil { + return CreatedAuthorization{}, err + } + } + if params.ResponseType == "code" && (!allowed || params.NativePlatform.Valid()) { + nativeApp, redirectURI, isApp, err = s.matchNativeApp(ctx, client.BotUserID, params.NativePlatform, params.RedirectURI) + if err != nil { + return CreatedAuthorization{}, err + } + if isApp { + source, domainName = domain.TelegramLoginRequestNative, nativeApp.ApplicationID + } + } + if params.ResponseType == "code" && !allowed && !isApp { + if redirectErr != nil { + return CreatedAuthorization{}, redirectErr + } + return CreatedAuthorization{}, domain.ErrTelegramLoginRedirectNotAllowed + } + if params.ResponseType == "post_message" && redirectErr != nil { + return CreatedAuthorization{}, redirectErr + } + if params.NativePlatform.Valid() && !isApp || source == domain.TelegramLoginRequestNative && !isApp { + return CreatedAuthorization{}, domain.ErrTelegramLoginRedirectNotAllowed + } + scopeValue := params.Scope + if isApp && !slices.Contains(strings.Fields(scopeValue), string(domain.TelegramLoginScopeOpenID)) { + scopeValue = string(domain.TelegramLoginScopeOpenID) + " " + scopeValue + } + scopes, err := ParseScopes(scopeValue, client.SigningAlgorithm) + if err != nil { + return CreatedAuthorization{}, err + } + if params.ResponseType == "code" || params.CodeChallenge != "" || params.CodeChallengeMethod != "" { + if err := ValidatePKCEChallenge(params.CodeChallenge, params.CodeChallengeMethod); err != nil { + return CreatedAuthorization{}, err + } + } + if len(params.State) > 2048 || len(params.Nonce) > 1024 || params.UserIDHint < 0 { + return CreatedAuthorization{}, domain.ErrTelegramLoginRequestInvalid + } + origin := "" + if !isApp { + origin = params.Origin + if origin == "" { + u, _ := url.Parse(redirectURI) + origin = u.Scheme + "://" + u.Host + } + origin, err = NormalizeWebOrigin(origin, s.allowLoopbackHTTP) + if err != nil { + return CreatedAuthorization{}, err + } + } + if params.ResponseType == "post_message" { + redirectURL, _ := url.Parse(redirectURI) + redirectOrigin, redirectOriginErr := NormalizeWebOrigin(redirectURL.Scheme+"://"+redirectURL.Host, s.allowLoopbackHTTP) + if redirectOriginErr != nil || redirectOrigin != origin { + return CreatedAuthorization{}, domain.ErrTelegramLoginOriginNotAllowed + } + allowed, err = s.store.IsTelegramLoginURLAllowed(ctx, client.BotUserID, domain.TelegramLoginAllowedWebOrigin, origin) + if err != nil { + return CreatedAuthorization{}, err + } + if !allowed { + return CreatedAuthorization{}, domain.ErrTelegramLoginOriginNotAllowed + } + } + inAppOrigin := "" + if params.InAppOrigin != "" { + if isApp { + return CreatedAuthorization{}, domain.ErrTelegramLoginOriginNotAllowed + } + inAppOrigin, err = NormalizeWebOrigin(params.InAppOrigin, s.allowLoopbackHTTP) + if err != nil { + return CreatedAuthorization{}, err + } + allowed, err = s.store.IsTelegramLoginURLAllowed(ctx, client.BotUserID, domain.TelegramLoginAllowedWebOrigin, inAppOrigin) + if err != nil { + return CreatedAuthorization{}, err + } + if !allowed { + return CreatedAuthorization{}, domain.ErrTelegramLoginOriginNotAllowed + } + } + requestToken, err := GenerateOpaqueToken() + if err != nil { + return CreatedAuthorization{}, err + } + browserToken, err := GenerateOpaqueToken() + if err != nil { + return CreatedAuthorization{}, err + } + matchCodes, matchCode, err := generateMatchCodes(params.IncludeMatchCodes) + if err != nil { + return CreatedAuthorization{}, err + } + now := s.now().UTC() + request := domain.TelegramLoginRequest{ + RequestTokenHash: HashOpaqueToken(requestToken), BrowserTokenHash: HashOpaqueToken(browserToken), + BotUserID: client.BotUserID, ClientID: client.ClientID, SigningAlgorithm: client.SigningAlgorithm, + Source: source, ResponseType: params.ResponseType, RedirectURI: redirectURI, Origin: origin, Domain: domainName, + Scopes: scopes, State: params.State, Nonce: params.Nonce, + CodeChallenge: params.CodeChallenge, CodeChallengeMethod: params.CodeChallengeMethod, + Browser: boundedValue(params.Browser, "Unknown browser", 255), Platform: boundedValue(params.Platform, "Unknown platform", 255), + IP: boundedValue(params.IP, "Unknown IP", 128), Region: boundedValue(params.Region, "Unknown region", 255), + InAppOrigin: inAppOrigin, IsApp: isApp, VerifiedAppName: nativeApp.VerifiedDisplayName, + MatchCodes: matchCodes, MatchCode: matchCode, MatchCodesFirst: params.MatchCodesFirst && len(matchCodes) > 0, + UserIDHint: params.UserIDHint, Status: domain.TelegramLoginRequestPending, + CreatedAt: now, ExpiresAt: now.Add(s.requestTTL), + } + request, err = s.store.CreateTelegramLoginRequest(ctx, request) + if err != nil { + return CreatedAuthorization{}, err + } + deepLink := s.appScheme + "://oauth?token=" + url.QueryEscape(requestToken) + return CreatedAuthorization{Request: request, RequestToken: requestToken, BrowserToken: browserToken, DeepLink: deepLink}, nil +} + +type AuthorizationErrorTarget struct { + ResponseType string + RedirectURI string + Origin string +} + +// ResolveAuthorizationErrorTarget validates an OAuth error destination +// independently of the invalid parameter that caused the request to fail. It +// prevents redirect and postMessage error handling from becoming an open +// redirect/origin oracle. +func (s *Service) ResolveAuthorizationErrorTarget(ctx context.Context, clientID, responseType, rawRedirectURI, rawOrigin string) (AuthorizationErrorTarget, bool, error) { + client, found, err := s.store.GetTelegramLoginClient(ctx, clientID) + if err != nil || !found || !client.Enabled { + return AuthorizationErrorTarget{}, false, err + } + switch responseType { + case "code": + redirectURI, safe, err := s.safeCodeRedirect(ctx, client.BotUserID, rawRedirectURI) + return AuthorizationErrorTarget{ResponseType: responseType, RedirectURI: redirectURI}, safe, err + case "post_message": + redirectURI, _, err := NormalizeRedirectURI(rawRedirectURI, s.allowLoopbackHTTP) + if err != nil { + return AuthorizationErrorTarget{}, false, nil + } + origin := rawOrigin + if origin == "" { + redirect, _ := url.Parse(redirectURI) + origin = redirect.Scheme + "://" + redirect.Host + } + origin, err = NormalizeWebOrigin(origin, s.allowLoopbackHTTP) + if err != nil { + return AuthorizationErrorTarget{}, false, nil + } + redirect, _ := url.Parse(redirectURI) + redirectOrigin, err := NormalizeWebOrigin(redirect.Scheme+"://"+redirect.Host, s.allowLoopbackHTTP) + if err != nil || redirectOrigin != origin { + return AuthorizationErrorTarget{}, false, nil + } + allowed, err := s.store.IsTelegramLoginURLAllowed(ctx, client.BotUserID, domain.TelegramLoginAllowedWebOrigin, origin) + if err != nil || !allowed { + return AuthorizationErrorTarget{}, false, err + } + return AuthorizationErrorTarget{ResponseType: responseType, RedirectURI: redirectURI, Origin: origin}, true, nil + default: + // An unsupported response_type may still report the error through a + // pre-registered redirect URI, per OAuth 2.0. + redirectURI, safe, err := s.safeCodeRedirect(ctx, client.BotUserID, rawRedirectURI) + return AuthorizationErrorTarget{ResponseType: "code", RedirectURI: redirectURI}, safe, err + } +} + +func (s *Service) safeCodeRedirect(ctx context.Context, botUserID int64, raw string) (string, bool, error) { + if redirectURI, _, err := NormalizeRedirectURI(raw, s.allowLoopbackHTTP); err == nil { + allowed, err := s.store.IsTelegramLoginURLAllowed(ctx, botUserID, domain.TelegramLoginAllowedRedirectURI, redirectURI) + if err != nil || allowed { + return redirectURI, allowed, err + } + } + _, redirectURI, allowed, err := s.matchNativeApp(ctx, botUserID, "", raw) + return redirectURI, allowed, err +} + +func ParseScopes(raw string, algorithm domain.TelegramLoginSigningAlgorithm) ([]domain.TelegramLoginScope, error) { + fields := strings.Fields(raw) + if len(fields) == 0 || len(fields) > 4 { + return nil, domain.ErrTelegramLoginScopeInvalid + } + set := make(map[domain.TelegramLoginScope]struct{}, len(fields)) + for _, field := range fields { + if field == "write" { + field = string(domain.TelegramLoginScopeBotAccess) + } + scope := domain.TelegramLoginScope(field) + if !scope.Valid() { + return nil, domain.ErrTelegramLoginScopeInvalid + } + if _, duplicate := set[scope]; duplicate { + return nil, domain.ErrTelegramLoginScopeInvalid + } + set[scope] = struct{}{} + } + ordered := make([]domain.TelegramLoginScope, 0, len(set)) + for _, scope := range []domain.TelegramLoginScope{ + domain.TelegramLoginScopeOpenID, + domain.TelegramLoginScopeProfile, + domain.TelegramLoginScopePhone, + domain.TelegramLoginScopeBotAccess, + } { + if _, ok := set[scope]; ok { + ordered = append(ordered, scope) + } + } + if err := domain.ValidateTelegramLoginScopes(ordered, algorithm); err != nil { + return nil, err + } + return ordered, nil +} + +func boundedValue(value, fallback string, max int) string { + value = strings.TrimSpace(strings.ToValidUTF8(value, "�")) + if value == "" { + value = fallback + } + for len(value) > max { + _, size := utf8.DecodeLastRuneInString(value) + value = value[:len(value)-size] + } + return value +} + +func generateMatchCodes(enabled bool) ([]string, string, error) { + if !enabled { + return []string{}, "", nil + } + pool := append([]string(nil), telegramLoginMatchCodePool...) + for i := len(pool) - 1; i > 0; i-- { + n, err := cryptoRandInt(i + 1) + if err != nil { + return nil, "", err + } + pool[i], pool[n] = pool[n], pool[i] + } + codes := append([]string(nil), pool[:5]...) + selected, err := cryptoRandInt(len(codes)) + if err != nil { + return nil, "", err + } + return codes, codes[selected], nil +} + +func cryptoRandInt(max int) (int, error) { + if max <= 0 { + return 0, fmt.Errorf("invalid crypto random bound") + } + var raw [8]byte + limit := ^uint64(0) - (^uint64(0) % uint64(max)) + for { + if _, err := rand.Read(raw[:]); err != nil { + return 0, fmt.Errorf("crypto random integer: %w", err) + } + value := binary.LittleEndian.Uint64(raw[:]) + if value < limit { + return int(value % uint64(max)), nil + } + } +} + +func (s *Service) RequestByDeepLink(ctx context.Context, rawURL string) (domain.TelegramLoginRequest, error) { + token, err := s.deepLinkToken(rawURL) + if err != nil { + return domain.TelegramLoginRequest{}, err + } + request, found, err := s.store.GetTelegramLoginRequestByTokenHash(ctx, HashOpaqueToken(token)) + if err != nil { + return domain.TelegramLoginRequest{}, err + } + if !found { + return domain.TelegramLoginRequest{}, domain.ErrTelegramLoginRequestInvalid + } + if !s.now().Before(request.ExpiresAt) { + return domain.TelegramLoginRequest{}, domain.ErrTelegramLoginRequestExpired + } + return request, nil +} + +// RequestByDeepLinkForOrigin resolves an OAuth deep link and proves the +// immutable Mini App origin that was bound when /inapp created the request. +// A copied deep link cannot be approved from another origin, and a normal web +// request can never be mutated into a Mini App request by a later MTProto call. +func (s *Service) RequestByDeepLinkForOrigin(ctx context.Context, rawURL, rawOrigin string) (domain.TelegramLoginRequest, error) { + request, err := s.RequestByDeepLink(ctx, rawURL) + if err != nil { + return domain.TelegramLoginRequest{}, err + } + if request.Source != domain.TelegramLoginRequestMiniApp { + if rawOrigin != "" || request.InAppOrigin != "" { + return domain.TelegramLoginRequest{}, domain.ErrTelegramLoginOriginNotAllowed + } + return request, nil + } + if rawOrigin == "" || request.InAppOrigin == "" { + return domain.TelegramLoginRequest{}, domain.ErrTelegramLoginOriginNotAllowed + } + origin, err := NormalizeWebOrigin(rawOrigin, s.allowLoopbackHTTP) + if err != nil { + return domain.TelegramLoginRequest{}, err + } + allowed, err := s.store.IsTelegramLoginURLAllowed(ctx, request.BotUserID, domain.TelegramLoginAllowedWebOrigin, origin) + if err != nil { + return domain.TelegramLoginRequest{}, err + } + if !allowed { + return domain.TelegramLoginRequest{}, domain.ErrTelegramLoginOriginNotAllowed + } + if request.InAppOrigin != origin { + return domain.TelegramLoginRequest{}, domain.ErrTelegramLoginOriginNotAllowed + } + return request, nil +} + +func (s *Service) deepLinkToken(rawURL string) (string, error) { + if rawURL == "" || len(rawURL) > maxTelegramLoginURLLength || rawURL != strings.TrimSpace(rawURL) { + return "", domain.ErrTelegramLoginURLInvalid + } + u, err := url.Parse(rawURL) + if err != nil || u.Fragment != "" || u.User != nil { + return "", domain.ErrTelegramLoginURLInvalid + } + query, err := url.ParseQuery(u.RawQuery) + if err != nil { + return "", domain.ErrTelegramLoginURLInvalid + } + customOrCanonicalScheme := strings.EqualFold(u.Scheme, s.appScheme) || strings.EqualFold(u.Scheme, "tg") + var token string + switch { + case customOrCanonicalScheme && strings.EqualFold(u.Host, "oauth") && u.Path == "": + token, _ = singleQueryValue(query, "token") + case customOrCanonicalScheme && strings.EqualFold(u.Host, "resolve") && u.Path == "": + domainValue, domainOK := singleQueryValue(query, "domain") + startApp, startAppOK := singleQueryValue(query, "startapp") + if domainOK && startAppOK && strings.EqualFold(domainValue, "oauth") { + token = startApp + } + case strings.EqualFold(u.Scheme, "https") && (strings.EqualFold(u.Hostname(), "t.me") || strings.EqualFold(u.Hostname(), "telegram.me")) && strings.Trim(u.Path, "/") == "oauth": + token, _ = singleQueryValue(query, "startapp") + default: + return "", domain.ErrTelegramLoginURLInvalid + } + if len(token) < 16 || len(token) > 1024 || strings.IndexFunc(token, func(r rune) bool { return r <= 0x20 || r == 0x7f }) >= 0 { + return "", domain.ErrTelegramLoginURLInvalid + } + return token, nil +} + +func singleQueryValue(query url.Values, name string) (string, bool) { + values, ok := query[name] + if !ok || len(values) != 1 { + return "", false + } + return values[0], true +} + +func (s *Service) CheckMatchCode(ctx context.Context, deepLink, selected string) (bool, error) { + request, err := s.RequestByDeepLink(ctx, deepLink) + if err != nil { + return false, err + } + if !request.MatchCodesFirst || len(request.MatchCodes) == 0 || !slices.Contains(request.MatchCodes, selected) { + return false, domain.ErrTelegramLoginMatchCodeInvalid + } + if subtle.ConstantTimeCompare([]byte(selected), []byte(request.MatchCode)) != 1 { + return false, domain.ErrTelegramLoginMatchCodeInvalid + } + return true, nil +} + +func (s *Service) Approve(ctx context.Context, deepLink string, identity domain.TelegramLoginIdentitySnapshot, writeAllowed, phoneShared bool, matchCode string) (domain.TelegramLoginRequest, domain.TelegramLoginWebAuthorization, error) { + request, err := s.RequestByDeepLink(ctx, deepLink) + if err != nil { + return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, err + } + hash, err := randomWebAuthorizationHash() + if err != nil { + return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, err + } + return s.store.ApproveTelegramLoginRequest(ctx, domain.TelegramLoginApproval{ + RequestID: request.ID, Identity: identity, WriteAllowed: writeAllowed, + PhoneShared: phoneShared, MatchCode: matchCode, ApprovedAt: s.now().UTC(), + }, hash) +} + +func randomWebAuthorizationHash() (int64, error) { + var raw [8]byte + if _, err := rand.Read(raw[:]); err != nil { + return 0, fmt.Errorf("generate web authorization hash: %w", err) + } + value := int64(binary.LittleEndian.Uint64(raw[:]) & uint64(^uint64(0)>>1)) + if value == 0 { + value = 1 + } + return value, nil +} + +func (s *Service) Decline(ctx context.Context, deepLink string, userID int64) (domain.TelegramLoginRequest, error) { + request, err := s.RequestByDeepLink(ctx, deepLink) + if err != nil { + return domain.TelegramLoginRequest{}, err + } + return s.store.DeclineTelegramLoginRequest(ctx, request.ID, userID, s.now().UTC()) +} + +type FinalizedAuthorization struct { + Request domain.TelegramLoginRequest + Code string + RedirectURL string +} + +func (s *Service) FinalizeByBrowserToken(ctx context.Context, browserToken string) (FinalizedAuthorization, error) { + request, found, err := s.store.GetTelegramLoginRequestByBrowserTokenHash(ctx, HashOpaqueToken(browserToken)) + if err != nil { + return FinalizedAuthorization{}, err + } + if !found || request.Status != domain.TelegramLoginRequestApproved || request.ResponseType != "code" { + return FinalizedAuthorization{}, domain.ErrTelegramLoginRequestConflict + } + return s.finalizeAuthorizationRequest(ctx, request) +} + +// FinalizeRedirectByDeepLink is used by native SDK cross-app flows: there is +// no browser poller, so messages.acceptUrlAuth must return the exact registered +// callback URI with a one-time authorization code. +func (s *Service) FinalizeRedirectByDeepLink(ctx context.Context, deepLink string) (string, error) { + token, err := s.deepLinkToken(deepLink) + if err != nil { + return "", err + } + request, found, err := s.store.GetTelegramLoginRequestByTokenHash(ctx, HashOpaqueToken(token)) + if err != nil { + return "", err + } + if !found || request.Status != domain.TelegramLoginRequestApproved || request.ResponseType != "code" || request.Source != domain.TelegramLoginRequestNative || !request.IsApp { + return "", domain.ErrTelegramLoginRequestConflict + } + finalized, err := s.finalizeAuthorizationRequest(ctx, request) + return finalized.RedirectURL, err +} + +// FinalizeInAppRedirectByDeepLink returns the result_url consumed by the +// official JavaScript SDK after the Telegram client emits +// oauth_result_confirmed. The URL contains only a short-lived one-time token; +// the ID token is signed and returned by /inapp after the webview proves the +// immutable requesting origin again. +func (s *Service) FinalizeInAppRedirectByDeepLink(ctx context.Context, deepLink string) (string, error) { + token, err := s.deepLinkToken(deepLink) + if err != nil { + return "", err + } + request, found, err := s.store.GetTelegramLoginRequestByTokenHash(ctx, HashOpaqueToken(token)) + if err != nil { + return "", err + } + if !found || request.Status != domain.TelegramLoginRequestApproved || + request.Source != domain.TelegramLoginRequestMiniApp || request.ResponseType != "post_message" || request.InAppOrigin == "" { + return "", domain.ErrTelegramLoginRequestConflict + } + directToken, err := s.finalizeInAppToken(ctx, request) + if err != nil { + return "", err + } + return s.issuer + "/inapp?token=" + url.QueryEscape(directToken), nil +} + +func (s *Service) finalizeInAppToken(ctx context.Context, request domain.TelegramLoginRequest) (string, error) { + if existing, found, err := s.store.GetTelegramLoginAuthorizationCodeByRequest(ctx, request.ID); err != nil { + return "", err + } else if found { + if !existing.ConsumedAt.IsZero() { + return "", domain.ErrTelegramLoginCodeConsumed + } + if !s.now().Before(existing.ExpiresAt) { + return "", domain.ErrTelegramLoginCodeInvalid + } + existing, err = s.store.PutTelegramLoginAuthorizationCode(ctx, existing) + if err != nil { + return "", err + } + return s.openCode(request, existing) + } + token, err := GenerateOpaqueToken() + if err != nil { + return "", err + } + sealed, nonce, keyID, err := s.sealer.Seal(token, codeAAD(request)) + if err != nil { + return "", err + } + now := s.now().UTC() + stored, err := s.store.PutTelegramLoginAuthorizationCode(ctx, domain.TelegramLoginAuthorizationCode{ + RequestID: request.ID, CodeHash: HashOpaqueToken(token), SealedCode: sealed, + SealNonce: nonce, SealKeyID: keyID, IssuedAt: now, ExpiresAt: now.Add(s.codeTTL), + }) + if err != nil { + return "", err + } + return s.openCode(request, stored) +} + +// ExchangeInAppTokenAndIssue performs the second official /inapp step. The +// direct token is one-time across all instances and remains bound to the +// original registered webview origin and active Web authorization. +func (s *Service) ExchangeInAppTokenAndIssue(ctx context.Context, token, rawOrigin string, issuer *IDTokenIssuer) (IssuedAuthorization, error) { + if issuer == nil || len(token) < 16 || len(token) > 1024 || strings.IndexFunc(token, func(r rune) bool { return r <= 0x20 || r == 0x7f }) >= 0 { + return IssuedAuthorization{}, domain.ErrTelegramLoginCodeInvalid + } + origin, err := NormalizeWebOrigin(rawOrigin, s.allowLoopbackHTTP) + if err != nil { + return IssuedAuthorization{}, domain.ErrTelegramLoginOriginNotAllowed + } + hash := HashOpaqueToken(token) + stored, found, err := s.store.GetTelegramLoginAuthorizationCodeByHash(ctx, hash) + if err != nil { + return IssuedAuthorization{}, err + } + if !found { + return IssuedAuthorization{}, domain.ErrTelegramLoginCodeInvalid + } + request, found, err := s.store.GetTelegramLoginRequest(ctx, stored.RequestID) + if err != nil { + return IssuedAuthorization{}, err + } + if !found || request.Source != domain.TelegramLoginRequestMiniApp || request.ResponseType != "post_message" || request.InAppOrigin != origin { + return IssuedAuthorization{}, domain.ErrTelegramLoginCodeInvalid + } + opened, err := s.openCode(request, stored) + if err != nil || subtle.ConstantTimeCompare([]byte(opened), []byte(token)) != 1 { + return IssuedAuthorization{}, domain.ErrTelegramLoginCodeInvalid + } + idToken, err := issuer.Issue(request) + if err != nil { + return IssuedAuthorization{}, err + } + _, consumed, web, err := s.store.ConsumeTelegramLoginDirectToken(ctx, hash, origin, s.now().UTC()) + if err != nil { + return IssuedAuthorization{}, err + } + return IssuedAuthorization{ + ExchangedAuthorization: ExchangedAuthorization{Request: consumed, WebAuthorization: web}, + IDToken: idToken, + }, nil +} + +func (s *Service) finalizeAuthorizationRequest(ctx context.Context, request domain.TelegramLoginRequest) (FinalizedAuthorization, error) { + if existing, found, err := s.store.GetTelegramLoginAuthorizationCodeByRequest(ctx, request.ID); err != nil { + return FinalizedAuthorization{}, err + } else if found { + if !existing.ConsumedAt.IsZero() { + return FinalizedAuthorization{}, domain.ErrTelegramLoginCodeConsumed + } + if !s.now().Before(existing.ExpiresAt) { + return FinalizedAuthorization{}, domain.ErrTelegramLoginCodeInvalid + } + existing, err = s.store.PutTelegramLoginAuthorizationCode(ctx, existing) + if err != nil { + return FinalizedAuthorization{}, err + } + code, err := s.openCode(request, existing) + if err != nil { + return FinalizedAuthorization{}, err + } + redirectURL, err := AppendAuthorizationResult(request.RedirectURI, code, request.State) + return FinalizedAuthorization{Request: request, Code: code, RedirectURL: redirectURL}, err + } + code, err := GenerateOpaqueToken() + if err != nil { + return FinalizedAuthorization{}, err + } + sealed, nonce, keyID, err := s.sealer.Seal(code, codeAAD(request)) + if err != nil { + return FinalizedAuthorization{}, err + } + now := s.now().UTC() + stored, err := s.store.PutTelegramLoginAuthorizationCode(ctx, domain.TelegramLoginAuthorizationCode{ + RequestID: request.ID, CodeHash: HashOpaqueToken(code), SealedCode: sealed, + SealNonce: nonce, SealKeyID: keyID, IssuedAt: now, ExpiresAt: now.Add(s.codeTTL), + }) + if err != nil { + return FinalizedAuthorization{}, err + } + code, err = s.openCode(request, stored) + if err != nil { + return FinalizedAuthorization{}, err + } + redirectURL, err := AppendAuthorizationResult(request.RedirectURI, code, request.State) + return FinalizedAuthorization{Request: request, Code: code, RedirectURL: redirectURL}, err +} + +type FinalizedDirectAuthorization struct { + Request domain.TelegramLoginRequest + IDToken string +} + +// FinalizeDirectByBrowserToken implements the JS SDK post_message response. +// The signed token is sealed in the durable artifact table so a lost HTTP +// response can retry and receive byte-identical output. Code consumption +// rejects post_message requests, so the artifact cannot be exchanged as an +// authorization code. +func (s *Service) FinalizeDirectByBrowserToken(ctx context.Context, browserToken string, issuer *IDTokenIssuer) (FinalizedDirectAuthorization, error) { + if issuer == nil { + return FinalizedDirectAuthorization{}, fmt.Errorf("telegram login ID token issuer is required") + } + request, err := s.RequestByBrowserToken(ctx, browserToken) + if err != nil { + return FinalizedDirectAuthorization{}, err + } + if request.Status != domain.TelegramLoginRequestApproved || request.ResponseType != "post_message" { + return FinalizedDirectAuthorization{}, domain.ErrTelegramLoginRequestConflict + } + if existing, found, err := s.store.GetTelegramLoginAuthorizationCodeByRequest(ctx, request.ID); err != nil { + return FinalizedDirectAuthorization{}, err + } else if found { + if !s.now().Before(existing.ExpiresAt) { + return FinalizedDirectAuthorization{}, domain.ErrTelegramLoginCodeInvalid + } + existing, err = s.store.PutTelegramLoginAuthorizationCode(ctx, existing) + if err != nil { + return FinalizedDirectAuthorization{}, err + } + idToken, err := s.openCode(request, existing) + return FinalizedDirectAuthorization{Request: request, IDToken: idToken}, err + } + idToken, err := issuer.Issue(request) + if err != nil { + return FinalizedDirectAuthorization{}, err + } + sealed, nonce, keyID, err := s.sealer.Seal(idToken, codeAAD(request)) + if err != nil { + return FinalizedDirectAuthorization{}, err + } + now := s.now().UTC() + stored, err := s.store.PutTelegramLoginAuthorizationCode(ctx, domain.TelegramLoginAuthorizationCode{ + RequestID: request.ID, CodeHash: HashOpaqueToken(idToken), SealedCode: sealed, + SealNonce: nonce, SealKeyID: keyID, IssuedAt: now, ExpiresAt: now.Add(s.codeTTL), + }) + if err != nil { + return FinalizedDirectAuthorization{}, err + } + idToken, err = s.openCode(request, stored) + return FinalizedDirectAuthorization{Request: request, IDToken: idToken}, err +} + +func codeAAD(request domain.TelegramLoginRequest) []byte { + return []byte(fmt.Sprintf("telesrv-telegram-login-code\x00%d\x00%s\x00%s", request.ID, request.ClientID, request.RedirectURI)) +} + +func (s *Service) openCode(request domain.TelegramLoginRequest, stored domain.TelegramLoginAuthorizationCode) (string, error) { + code, err := s.sealer.Open(stored.SealedCode, stored.SealNonce, stored.SealKeyID, codeAAD(request)) + if err != nil || subtle.ConstantTimeCompare(HashOpaqueToken(code), stored.CodeHash) != 1 { + return "", domain.ErrTelegramLoginCodeInvalid + } + return code, nil +} + +type ExchangeAuthorizationCodeParams struct { + Code string + ClientID string + ClientSecret string + RedirectURI string + CodeVerifier string + PublicNativeClient bool +} + +type ExchangedAuthorization struct { + Request domain.TelegramLoginRequest + WebAuthorization domain.TelegramLoginWebAuthorization +} + +func (s *Service) ExchangeAuthorizationCode(ctx context.Context, params ExchangeAuthorizationCodeParams) (ExchangedAuthorization, error) { + exchanged, _, err := s.exchangeAuthorizationCode(ctx, params, nil) + return exchanged, err +} + +type IssuedAuthorization struct { + ExchangedAuthorization + IDToken string +} + +// ExchangeAuthorizationCodeAndIssue signs the immutable approval snapshot +// before the one-time code is consumed, but does not expose the signed token +// unless the locked consume succeeds. This keeps a transient signing/key error +// retryable while preserving exactly-once code exchange under concurrency. +func (s *Service) ExchangeAuthorizationCodeAndIssue(ctx context.Context, params ExchangeAuthorizationCodeParams, issuer *IDTokenIssuer) (IssuedAuthorization, error) { + if issuer == nil { + return IssuedAuthorization{}, fmt.Errorf("telegram login ID token issuer is required") + } + exchanged, idToken, err := s.exchangeAuthorizationCode(ctx, params, issuer.Issue) + if err != nil { + return IssuedAuthorization{}, err + } + return IssuedAuthorization{ExchangedAuthorization: exchanged, IDToken: idToken}, nil +} + +func (s *Service) exchangeAuthorizationCode(ctx context.Context, params ExchangeAuthorizationCodeParams, issue func(domain.TelegramLoginRequest) (string, error)) (ExchangedAuthorization, string, error) { + client, found, err := s.store.GetTelegramLoginClient(ctx, params.ClientID) + if err != nil { + return ExchangedAuthorization{}, "", err + } + if !found || !client.Enabled { + return ExchangedAuthorization{}, "", domain.ErrTelegramLoginSecretInvalid + } + challenge, err := PKCEChallenge(params.CodeVerifier) + if err != nil { + return ExchangedAuthorization{}, "", err + } + codeHash := HashOpaqueToken(params.Code) + stored, found, err := s.store.GetTelegramLoginAuthorizationCodeByHash(ctx, codeHash) + if err != nil { + return ExchangedAuthorization{}, "", err + } + if !found { + return ExchangedAuthorization{}, "", domain.ErrTelegramLoginCodeInvalid + } + // The request is recovered by the durable consume operation. Opening the + // sealed code first prevents storage corruption/key loss from consuming it. + requestByBrowser, found, err := s.store.GetTelegramLoginRequest(ctx, stored.RequestID) + if err != nil || !found { + if err != nil { + return ExchangedAuthorization{}, "", err + } + return ExchangedAuthorization{}, "", domain.ErrTelegramLoginCodeInvalid + } + var redirectURI string + if requestByBrowser.Source == domain.TelegramLoginRequestNative { + var allowed bool + _, redirectURI, allowed, err = s.matchNativeApp(ctx, client.BotUserID, "", params.RedirectURI) + if err != nil { + return ExchangedAuthorization{}, "", err + } + if !allowed { + return ExchangedAuthorization{}, "", domain.ErrTelegramLoginCodeInvalid + } + } else { + redirectURI, _, err = NormalizeRedirectURI(params.RedirectURI, s.allowLoopbackHTTP) + if err != nil { + return ExchangedAuthorization{}, "", err + } + } + if params.PublicNativeClient { + if requestByBrowser.Source != domain.TelegramLoginRequestNative || params.ClientSecret != "" { + return ExchangedAuthorization{}, "", domain.ErrTelegramLoginSecretInvalid + } + } else if !VerifyClientSecret(s.clientSecretPepper, params.ClientSecret, client.SecretHash) { + return ExchangedAuthorization{}, "", domain.ErrTelegramLoginSecretInvalid + } + opened, err := s.openCode(requestByBrowser, stored) + if err != nil || subtle.ConstantTimeCompare([]byte(opened), []byte(params.Code)) != 1 { + return ExchangedAuthorization{}, "", domain.ErrTelegramLoginCodeInvalid + } + var idToken string + if issue != nil { + idToken, err = issue(requestByBrowser) + if err != nil { + return ExchangedAuthorization{}, "", err + } + } + _, consumedRequest, web, err := s.store.ConsumeTelegramLoginAuthorizationCode(ctx, domain.TelegramLoginCodeExchange{ + CodeHash: codeHash, ClientID: params.ClientID, ClientSecretVersion: client.SecretVersion, + RedirectURI: redirectURI, CodeChallenge: challenge, Now: s.now().UTC(), + }) + if err != nil { + return ExchangedAuthorization{}, "", err + } + return ExchangedAuthorization{Request: consumedRequest, WebAuthorization: web}, idToken, nil +} + +func (s *Service) RequestByBrowserToken(ctx context.Context, browserToken string) (domain.TelegramLoginRequest, error) { + if len(browserToken) < 16 || len(browserToken) > 1024 || strings.IndexFunc(browserToken, func(r rune) bool { return r <= 0x20 || r == 0x7f }) >= 0 { + return domain.TelegramLoginRequest{}, domain.ErrTelegramLoginRequestInvalid + } + request, found, err := s.store.GetTelegramLoginRequestByBrowserTokenHash(ctx, HashOpaqueToken(browserToken)) + if err != nil { + return domain.TelegramLoginRequest{}, err + } + if !found { + return domain.TelegramLoginRequest{}, domain.ErrTelegramLoginRequestInvalid + } + if request.Status == domain.TelegramLoginRequestPending && !s.now().Before(request.ExpiresAt) { + request.Status = domain.TelegramLoginRequestExpired + } + return request, nil +} + +func (s *Service) ListWebAuthorizations(ctx context.Context, userID int64) ([]domain.TelegramLoginWebAuthorization, error) { + return s.store.ListTelegramLoginWebAuthorizations(ctx, userID) +} + +func (s *Service) RevokeWebAuthorization(ctx context.Context, userID, hash int64) error { + revoked, err := s.store.RevokeTelegramLoginWebAuthorization(ctx, userID, hash, s.now().UTC()) + if err != nil { + return err + } + if !revoked { + return domain.ErrTelegramLoginWebAuthHashInvalid + } + return nil +} + +func (s *Service) RevokeAllWebAuthorizations(ctx context.Context, userID int64) (int64, error) { + return s.store.RevokeAllTelegramLoginWebAuthorizations(ctx, userID, s.now().UTC()) +} + +// DeleteExpiredArtifacts removes only terminal login artifacts older than the +// configured retention boundary. Active web authorizations remain durable and +// keep their immutable approved request/claim snapshot reachable. +func (s *Service) DeleteExpiredArtifacts(ctx context.Context, before time.Time, limit int) (int64, error) { + if before.IsZero() || limit <= 0 || limit > 1000 { + return 0, domain.ErrTelegramLoginRequestInvalid + } + return s.store.DeleteExpiredTelegramLoginArtifacts(ctx, before.UTC(), limit) +} diff --git a/internal/app/telegramlogin/service_test.go b/internal/app/telegramlogin/service_test.go new file mode 100644 index 00000000..d0d02216 --- /dev/null +++ b/internal/app/telegramlogin/service_test.go @@ -0,0 +1,404 @@ +package telegramlogin + +import ( + "context" + "errors" + "net/url" + "sync" + "sync/atomic" + "testing" + "time" + + "telesrv/internal/domain" + "telesrv/internal/store/memory" +) + +func TestServiceClientCreationAndSecretRotationAreSingleWinner(t *testing.T) { + ctx := context.Background() + now := time.Unix(1_780_000_000, 0).UTC() + service, loginStore := newTelegramLoginTestService(t, &now) + + const contenders = 24 + start := make(chan struct{}) + var wg sync.WaitGroup + var created atomic.Int32 + var conflicts atomic.Int32 + for i := 0; i < contenders; i++ { + wg.Add(1) + go func() { + defer wg.Done() + <-start + _, err := service.CreateClient(ctx, 9010, domain.TelegramLoginSigningRS256) + switch { + case err == nil: + created.Add(1) + case errors.Is(err, domain.ErrTelegramLoginRequestConflict): + conflicts.Add(1) + default: + t.Errorf("CreateClient: %v", err) + } + }() + } + close(start) + wg.Wait() + if created.Load() != 1 || conflicts.Load() != contenders-1 { + t.Fatalf("create winners=%d conflicts=%d", created.Load(), conflicts.Load()) + } + + client, found, err := loginStore.GetTelegramLoginClientByBot(ctx, 9010) + if err != nil || !found { + t.Fatalf("GetTelegramLoginClientByBot: found=%v err=%v", found, err) + } + start = make(chan struct{}) + created.Store(0) + conflicts.Store(0) + for i := 0; i < contenders; i++ { + wg.Add(1) + go func(seed byte) { + defer wg.Done() + <-start + hash := make([]byte, 32) + hash[0] = seed + _, err := loginStore.RotateTelegramLoginClientSecret(ctx, client.BotUserID, client.SecretVersion, hash, now.Add(time.Second)) + switch { + case err == nil: + created.Add(1) + case errors.Is(err, domain.ErrTelegramLoginRequestConflict): + conflicts.Add(1) + default: + t.Errorf("RotateTelegramLoginClientSecret: %v", err) + } + }(byte(i + 1)) + } + close(start) + wg.Wait() + if created.Load() != 1 || conflicts.Load() != contenders-1 { + t.Fatalf("rotate winners=%d conflicts=%d", created.Load(), conflicts.Load()) + } +} + +func newTelegramLoginTestService(t *testing.T, now *time.Time) (*Service, *memory.TelegramLoginStore) { + return newTelegramLoginTestServiceWithAlgorithms(t, now, nil) +} + +func newTelegramLoginTestServiceWithAlgorithms(t *testing.T, now *time.Time, algorithms []domain.TelegramLoginSigningAlgorithm) (*Service, *memory.TelegramLoginStore) { + t.Helper() + key := make([]byte, 32) + key[0] = 7 + sealer, err := NewCodeSealer("test", map[string][]byte{"test": key}) + if err != nil { + t.Fatal(err) + } + loginStore := memory.NewTelegramLoginStore(nil) + pepper := make([]byte, 32) + pepper[0] = 9 + service, err := NewService(loginStore, sealer, Config{ + Issuer: "https://oauth.telesrv.test", AppScheme: "telesrv", + AllowLoopbackHTTP: true, ClientSecretPepper: pepper, + SupportedSigningAlgorithms: algorithms, + Now: func() time.Time { return *now }, + }) + if err != nil { + t.Fatal(err) + } + return service, loginStore +} + +func TestServiceAcceptsOfficialClientCanonicalOAuthDeepLinks(t *testing.T) { + ctx := context.Background() + now := time.Unix(1_780_000_000, 0).UTC() + service, _ := newTelegramLoginTestService(t, &now) + credentials, err := service.CreateClient(ctx, 9030, domain.TelegramLoginSigningRS256) + if err != nil { + t.Fatal(err) + } + const redirectURI = "https://rp.example/callback" + if _, err := service.AddAllowedURL(ctx, 9030, domain.TelegramLoginAllowedRedirectURI, redirectURI); err != nil { + t.Fatal(err) + } + challenge, err := PKCEChallenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk") + if err != nil { + t.Fatal(err) + } + created, err := service.CreateAuthorization(ctx, CreateAuthorizationParams{ + ClientID: credentials.Client.ClientID, RedirectURI: redirectURI, ResponseType: "code", + Scope: "openid", CodeChallenge: challenge, CodeChallengeMethod: "S256", + }) + if err != nil { + t.Fatal(err) + } + parsed, err := url.Parse(created.DeepLink) + if err != nil { + t.Fatal(err) + } + token := parsed.Query().Get("token") + valid := []string{ + created.DeepLink, + "tg://oauth?token=" + url.QueryEscape(token), + "tg://resolve?domain=oauth&startapp=" + url.QueryEscape(token), + "https://t.me/oauth?startapp=" + url.QueryEscape(token), + } + for _, deepLink := range valid { + request, err := service.RequestByDeepLink(ctx, deepLink) + if err != nil || request.ID != created.Request.ID { + t.Fatalf("RequestByDeepLink(%q) request=%#v err=%v", deepLink, request, err) + } + } + invalid := []string{ + "telegram://oauth?token=" + url.QueryEscape(token), + "tg://oauth/path?token=" + url.QueryEscape(token), + "tg://oauth?token=" + url.QueryEscape(token) + "&token=other", + "tg://resolve?domain=oauth&domain=other&startapp=" + url.QueryEscape(token), + "tg://resolve?domain=oauth&startapp=" + url.QueryEscape(token) + "&startapp=other", + "tg://oauth?token=" + url.QueryEscape(token) + "#fragment", + } + for _, deepLink := range invalid { + if _, err := service.RequestByDeepLink(ctx, deepLink); !errors.Is(err, domain.ErrTelegramLoginURLInvalid) { + t.Fatalf("RequestByDeepLink(%q) error=%v, want URL invalid", deepLink, err) + } + } +} + +func TestServiceRejectsSigningAlgorithmsWithoutActiveKeys(t *testing.T) { + ctx := context.Background() + now := time.Unix(1_780_000_000, 0).UTC() + service, loginStore := newTelegramLoginTestServiceWithAlgorithms(t, &now, []domain.TelegramLoginSigningAlgorithm{ + domain.TelegramLoginSigningES256, + }) + if _, err := service.CreateClient(ctx, 9020, domain.TelegramLoginSigningRS256); !errors.Is(err, domain.ErrTelegramLoginClientInvalid) { + t.Fatalf("CreateClient unsupported algorithm error=%v", err) + } + credentials, created, err := service.EnsureClient(ctx, 9020) + if err != nil || !created || credentials.Client.SigningAlgorithm != domain.TelegramLoginSigningES256 { + t.Fatalf("EnsureClient credentials=%#v created=%v err=%v", credentials, created, err) + } + if _, err := service.SetClientSigningAlgorithm(ctx, 9020, domain.TelegramLoginSigningEdDSA); !errors.Is(err, domain.ErrTelegramLoginClientInvalid) { + t.Fatalf("SetClientSigningAlgorithm unsupported error=%v", err) + } + + // Simulate configuration drift from a previous deployment. Authorization + // must fail before a request is persisted instead of failing after consent. + if _, err := loginStore.SetTelegramLoginClientSigningAlgorithm(ctx, 9020, domain.TelegramLoginSigningRS256, now.Add(time.Second)); err != nil { + t.Fatal(err) + } + if err := service.SetClientEnabled(ctx, 9020, false); err != nil { + t.Fatal(err) + } + if err := service.SetClientEnabled(ctx, 9020, true); !errors.Is(err, domain.ErrTelegramLoginClientInvalid) { + t.Fatalf("SetClientEnabled unavailable algorithm error=%v", err) + } + if _, err := service.AddAllowedURL(ctx, 9020, domain.TelegramLoginAllowedWebOrigin, "https://rp.example"); err != nil { + t.Fatal(err) + } + if _, err := service.CreateAuthorization(ctx, CreateAuthorizationParams{ + ClientID: credentials.Client.ClientID, RedirectURI: "https://rp.example/", ResponseType: "post_message", + Scope: "openid profile", + }); !errors.Is(err, domain.ErrTelegramLoginClientDisabled) { + t.Fatalf("CreateAuthorization unavailable algorithm error=%v", err) + } +} + +func TestServiceAuthorizationCodeFlowAndRevocation(t *testing.T) { + ctx := context.Background() + now := time.Unix(1_780_000_000, 0).UTC() + service, _ := newTelegramLoginTestService(t, &now) + credentials, err := service.CreateClient(ctx, 9001, domain.TelegramLoginSigningRS256) + if err != nil { + t.Fatalf("CreateClient: %v", err) + } + const redirectURI = "https://rp.example/callback" + if _, err := service.AddAllowedURL(ctx, 9001, domain.TelegramLoginAllowedRedirectURI, redirectURI); err != nil { + t.Fatalf("AddAllowedURL redirect: %v", err) + } + if _, err := service.AddAllowedURL(ctx, 9001, domain.TelegramLoginAllowedWebOrigin, "https://rp.example"); err != nil { + t.Fatalf("AddAllowedURL origin: %v", err) + } + const verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + challenge, _ := PKCEChallenge(verifier) + created, err := service.CreateAuthorization(ctx, CreateAuthorizationParams{ + ClientID: credentials.Client.ClientID, RedirectURI: redirectURI, + ResponseType: "code", Scope: "openid profile phone telegram:bot_access", + State: "opaque-state", Nonce: "nonce", CodeChallenge: challenge, CodeChallengeMethod: "S256", + Browser: "Firefox", Platform: "Windows", IP: "192.0.2.10", Region: "Test Region", + IncludeMatchCodes: true, MatchCodesFirst: true, + }) + if err != nil { + t.Fatalf("CreateAuthorization: %v", err) + } + if created.DeepLink == "" || created.Request.ID == 0 || len(created.Request.MatchCodes) != 5 { + t.Fatalf("created authorization = %#v", created) + } + if _, err := service.CheckMatchCode(ctx, created.DeepLink, created.Request.MatchCodes[0]); err == nil && created.Request.MatchCodes[0] != created.Request.MatchCode { + t.Fatal("wrong match code unexpectedly accepted") + } + if ok, err := service.CheckMatchCode(ctx, created.DeepLink, created.Request.MatchCode); err != nil || !ok { + t.Fatalf("CheckMatchCode correct = %v,%v", ok, err) + } + now = now.Add(time.Second) + identity := domain.TelegramLoginIdentitySnapshot{ + UserID: 42, Name: "Alice Example", GivenName: "Alice", FamilyName: "Example", + PreferredUsername: "alice", Picture: "https://oauth.telesrv.test/userpic/42", + } + approved, web, err := service.Approve(ctx, created.DeepLink, identity, true, false, created.Request.MatchCode) + if err != nil { + t.Fatalf("Approve: %v", err) + } + if approved.Status != domain.TelegramLoginRequestApproved || web.PhoneShared || !web.BotAccessGranted { + t.Fatalf("approved=%#v web=%#v", approved, web) + } + if approved.ProfileName != "Alice Example" || approved.PhoneNumber != "" { + t.Fatalf("identity snapshot = %#v", approved) + } + now = now.Add(time.Second) + finalized, err := service.FinalizeByBrowserToken(ctx, created.BrowserToken) + if err != nil { + t.Fatalf("FinalizeByBrowserToken: %v", err) + } + redirect, err := url.Parse(finalized.RedirectURL) + if err != nil || redirect.Query().Get("code") != finalized.Code || redirect.Query().Get("state") != "opaque-state" { + t.Fatalf("final redirect = %q,%v", finalized.RedirectURL, err) + } + if _, err := service.ExchangeAuthorizationCode(ctx, ExchangeAuthorizationCodeParams{ + Code: finalized.Code, ClientID: credentials.Client.ClientID, ClientSecret: credentials.Secret, + RedirectURI: redirectURI, CodeVerifier: verifier + "x", + }); !errors.Is(err, domain.ErrTelegramLoginCodeInvalid) { + t.Fatalf("exchange wrong verifier error = %v, want code invalid", err) + } + now = now.Add(time.Second) + exchanged, err := service.ExchangeAuthorizationCode(ctx, ExchangeAuthorizationCodeParams{ + Code: finalized.Code, ClientID: credentials.Client.ClientID, ClientSecret: credentials.Secret, + RedirectURI: redirectURI, CodeVerifier: verifier, + }) + if err != nil { + t.Fatalf("ExchangeAuthorizationCode: %v", err) + } + if exchanged.Request.AuthorizedUserID != 42 || exchanged.WebAuthorization.Hash != web.Hash { + t.Fatalf("exchanged = %#v", exchanged) + } + if _, err := service.ExchangeAuthorizationCode(ctx, ExchangeAuthorizationCodeParams{ + Code: finalized.Code, ClientID: credentials.Client.ClientID, ClientSecret: credentials.Secret, + RedirectURI: redirectURI, CodeVerifier: verifier, + }); !errors.Is(err, domain.ErrTelegramLoginCodeConsumed) { + t.Fatalf("replay exchange error = %v, want consumed", err) + } + if err := service.RevokeWebAuthorization(ctx, 42, web.Hash); err != nil { + t.Fatalf("RevokeWebAuthorization: %v", err) + } + if list, err := service.ListWebAuthorizations(ctx, 42); err != nil || len(list) != 0 { + t.Fatalf("ListWebAuthorizations after revoke = %#v,%v", list, err) + } + if err := service.RevokeWebAuthorization(ctx, 42, web.Hash); !errors.Is(err, domain.ErrTelegramLoginWebAuthHashInvalid) { + t.Fatalf("second revoke error = %v, want hash invalid", err) + } +} + +func TestFinalizationRetryRechecksLiveAuthorization(t *testing.T) { + ctx := context.Background() + now := time.Unix(1_780_000_000, 0).UTC() + service, _ := newTelegramLoginTestService(t, &now) + credentials, err := service.CreateClient(ctx, 9010, domain.TelegramLoginSigningRS256) + if err != nil { + t.Fatal(err) + } + const redirectURI = "https://retry.example/callback" + const origin = "https://retry.example" + if _, err := service.AddAllowedURL(ctx, 9010, domain.TelegramLoginAllowedRedirectURI, redirectURI); err != nil { + t.Fatal(err) + } + if _, err := service.AddAllowedURL(ctx, 9010, domain.TelegramLoginAllowedWebOrigin, origin); err != nil { + t.Fatal(err) + } + challenge, err := PKCEChallenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk") + if err != nil { + t.Fatal(err) + } + codeRequest, err := service.CreateAuthorization(ctx, CreateAuthorizationParams{ + ClientID: credentials.Client.ClientID, RedirectURI: redirectURI, ResponseType: "code", + Scope: "openid", CodeChallenge: challenge, CodeChallengeMethod: "S256", + }) + if err != nil { + t.Fatal(err) + } + _, codeWeb, err := service.Approve(ctx, codeRequest.DeepLink, domain.TelegramLoginIdentitySnapshot{UserID: 51}, false, false, "") + if err != nil { + t.Fatal(err) + } + if _, err := service.FinalizeByBrowserToken(ctx, codeRequest.BrowserToken); err != nil { + t.Fatal(err) + } + if err := service.RevokeWebAuthorization(ctx, 51, codeWeb.Hash); err != nil { + t.Fatal(err) + } + if _, err := service.FinalizeByBrowserToken(ctx, codeRequest.BrowserToken); !errors.Is(err, domain.ErrTelegramLoginRequestConflict) { + t.Fatalf("authorization-code retry after revoke error = %v, want conflict", err) + } + + miniRequest, err := service.CreateAuthorization(ctx, CreateAuthorizationParams{ + ClientID: credentials.Client.ClientID, RedirectURI: origin + "/", ResponseType: "post_message", Scope: "openid", + Origin: origin, InAppOrigin: origin, Source: domain.TelegramLoginRequestMiniApp, + }) + if err != nil { + t.Fatal(err) + } + _, miniWeb, err := service.Approve(ctx, miniRequest.DeepLink, domain.TelegramLoginIdentitySnapshot{UserID: 52}, false, false, "") + if err != nil { + t.Fatal(err) + } + if _, err := service.FinalizeInAppRedirectByDeepLink(ctx, miniRequest.DeepLink); err != nil { + t.Fatal(err) + } + if err := service.RevokeWebAuthorization(ctx, 52, miniWeb.Hash); err != nil { + t.Fatal(err) + } + if _, err := service.FinalizeInAppRedirectByDeepLink(ctx, miniRequest.DeepLink); !errors.Is(err, domain.ErrTelegramLoginRequestConflict) { + t.Fatalf("Mini App token retry after revoke error = %v, want conflict", err) + } +} + +func TestServiceSecretRotationClosesExchangeTOCTOU(t *testing.T) { + ctx := context.Background() + now := time.Unix(1_780_000_000, 0).UTC() + service, _ := newTelegramLoginTestService(t, &now) + oldCredentials, err := service.CreateClient(ctx, 9002, domain.TelegramLoginSigningRS256) + if err != nil { + t.Fatal(err) + } + const redirectURI = "https://rotate.example/callback" + if _, err := service.AddAllowedURL(ctx, 9002, domain.TelegramLoginAllowedRedirectURI, redirectURI); err != nil { + t.Fatal(err) + } + const verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + challenge, _ := PKCEChallenge(verifier) + created, err := service.CreateAuthorization(ctx, CreateAuthorizationParams{ + ClientID: oldCredentials.Client.ClientID, RedirectURI: redirectURI, ResponseType: "code", + Scope: "openid", CodeChallenge: challenge, CodeChallengeMethod: "S256", + }) + if err != nil { + t.Fatal(err) + } + now = now.Add(time.Second) + if _, _, err := service.Approve(ctx, created.DeepLink, domain.TelegramLoginIdentitySnapshot{UserID: 43}, false, false, ""); err != nil { + t.Fatal(err) + } + now = now.Add(time.Second) + finalized, err := service.FinalizeByBrowserToken(ctx, created.BrowserToken) + if err != nil { + t.Fatal(err) + } + newCredentials, err := service.RotateClientSecret(ctx, 9002) + if err != nil { + t.Fatal(err) + } + if _, err := service.ExchangeAuthorizationCode(ctx, ExchangeAuthorizationCodeParams{ + Code: finalized.Code, ClientID: oldCredentials.Client.ClientID, ClientSecret: oldCredentials.Secret, + RedirectURI: redirectURI, CodeVerifier: verifier, + }); !errors.Is(err, domain.ErrTelegramLoginSecretInvalid) { + t.Fatalf("old secret exchange error = %v", err) + } + if _, err := service.ExchangeAuthorizationCode(ctx, ExchangeAuthorizationCodeParams{ + Code: finalized.Code, ClientID: newCredentials.Client.ClientID, ClientSecret: newCredentials.Secret, + RedirectURI: redirectURI, CodeVerifier: verifier, + }); err != nil { + t.Fatalf("new secret exchange: %v", err) + } +} diff --git a/internal/app/telegramlogin/url.go b/internal/app/telegramlogin/url.go new file mode 100644 index 00000000..dfc9a21a --- /dev/null +++ b/internal/app/telegramlogin/url.go @@ -0,0 +1,141 @@ +package telegramlogin + +import ( + "net" + "net/url" + "strconv" + "strings" + "unicode" + + "golang.org/x/net/idna" + + "telesrv/internal/domain" +) + +const maxTelegramLoginURLLength = 4096 + +func NormalizeRedirectURI(raw string, allowLoopbackHTTP bool) (normalized, domainName string, err error) { + u, err := parseWebURL(raw, allowLoopbackHTTP) + if err != nil { + return "", "", err + } + if u.Fragment != "" { + return "", "", domain.ErrTelegramLoginURLInvalid + } + query := u.Query() + for _, reserved := range []string{"code", "state", "error", "error_description"} { + if _, exists := query[reserved]; exists { + return "", "", domain.ErrTelegramLoginURLInvalid + } + } + if u.Path == "" { + u.Path = "/" + } + return u.String(), u.Hostname(), nil +} + +func NormalizeWebOrigin(raw string, allowLoopbackHTTP bool) (string, error) { + u, err := parseWebURL(raw, allowLoopbackHTTP) + if err != nil { + return "", err + } + if (u.Path != "" && u.Path != "/") || u.RawQuery != "" || u.Fragment != "" || u.RawPath != "" { + return "", domain.ErrTelegramLoginURLInvalid + } + u.Path = "" + return u.String(), nil +} + +func parseWebURL(raw string, allowLoopbackHTTP bool) (*url.URL, error) { + if raw == "" || len(raw) > maxTelegramLoginURLLength || raw != strings.TrimSpace(raw) || strings.IndexFunc(raw, unicode.IsControl) >= 0 { + return nil, domain.ErrTelegramLoginURLInvalid + } + u, err := url.Parse(raw) + if err != nil || !u.IsAbs() || u.Opaque != "" || u.User != nil || u.Host == "" { + return nil, domain.ErrTelegramLoginURLInvalid + } + u.Scheme = strings.ToLower(u.Scheme) + host := strings.TrimSuffix(strings.ToLower(u.Hostname()), ".") + if host == "" { + return nil, domain.ErrTelegramLoginURLInvalid + } + if ip := net.ParseIP(host); ip == nil { + host, err = idna.Lookup.ToASCII(host) + if err != nil || host == "" { + return nil, domain.ErrTelegramLoginURLInvalid + } + } + port := u.Port() + if port != "" { + n, err := strconv.Atoi(port) + if err != nil || n < 1 || n > 65535 { + return nil, domain.ErrTelegramLoginURLInvalid + } + } + switch u.Scheme { + case "https": + if port == "443" { + port = "" + } + case "http": + if !allowLoopbackHTTP || !isLoopbackHost(host) { + return nil, domain.ErrTelegramLoginURLInvalid + } + if port == "80" { + port = "" + } + default: + return nil, domain.ErrTelegramLoginURLInvalid + } + if port == "" { + if ip := net.ParseIP(host); ip != nil && strings.Contains(host, ":") { + u.Host = "[" + host + "]" + } else { + u.Host = host + } + } else { + u.Host = net.JoinHostPort(host, port) + } + return u, nil +} + +func isLoopbackHost(host string) bool { + if host == "localhost" { + return true + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} + +func AppendAuthorizationResult(redirectURI, code, state string) (string, error) { + u, err := url.Parse(redirectURI) + if err != nil || !u.IsAbs() || code == "" { + return "", domain.ErrTelegramLoginURLInvalid + } + q := u.Query() + q.Set("code", code) + if state != "" { + q.Set("state", state) + } + u.RawQuery = q.Encode() + return u.String(), nil +} + +func AppendAuthorizationError(redirectURI, errorCode, state string) (string, error) { + switch errorCode { + case "access_denied", "temporarily_unavailable", "server_error", "invalid_request", "invalid_scope", "unsupported_response_type": + default: + return "", domain.ErrTelegramLoginRequestInvalid + } + u, err := url.Parse(redirectURI) + if err != nil || !u.IsAbs() { + return "", domain.ErrTelegramLoginURLInvalid + } + q := u.Query() + q.Set("error", errorCode) + if state != "" { + q.Set("state", state) + } + u.RawQuery = q.Encode() + return u.String(), nil +} diff --git a/internal/app/telegramlogin/url_test.go b/internal/app/telegramlogin/url_test.go new file mode 100644 index 00000000..17dc4549 --- /dev/null +++ b/internal/app/telegramlogin/url_test.go @@ -0,0 +1,115 @@ +package telegramlogin + +import ( + "errors" + "net/url" + "testing" + + "telesrv/internal/domain" +) + +func TestNormalizeRedirectURIIsExactAndRejectsOpenRedirectShapes(t *testing.T) { + tests := []struct { + name string + raw string + allowHTTP bool + want string + valid bool + }{ + {name: "https canonical", raw: "https://EXAMPLE.com:443/callback?tenant=one", want: "https://example.com/callback?tenant=one", valid: true}, + {name: "idna", raw: "https://例子.测试/callback", want: "https://xn--fsqu00a.xn--0zwm56d/callback", valid: true}, + {name: "loopback dev", raw: "http://127.0.0.1:8080/callback", allowHTTP: true, want: "http://127.0.0.1:8080/callback", valid: true}, + {name: "http production", raw: "http://example.com/callback"}, + {name: "userinfo", raw: "https://user@example.com/callback"}, + {name: "fragment", raw: "https://example.com/callback#token"}, + {name: "reserved code", raw: "https://example.com/callback?code=attacker"}, + {name: "leading whitespace", raw: " https://example.com/callback"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, _, err := NormalizeRedirectURI(test.raw, test.allowHTTP) + if test.valid { + if err != nil || got != test.want { + t.Fatalf("NormalizeRedirectURI() = %q,%v, want %q,nil", got, err, test.want) + } + } else if !errors.Is(err, domain.ErrTelegramLoginURLInvalid) { + t.Fatalf("NormalizeRedirectURI() error = %v, want URL invalid", err) + } + }) + } +} + +func TestAppendAuthorizationErrorPreservesState(t *testing.T) { + got, err := AppendAuthorizationError("https://example.com/callback?tenant=one", "access_denied", "opaque") + if err != nil { + t.Fatal(err) + } + u, _ := url.Parse(got) + if u.Query().Get("tenant") != "one" || u.Query().Get("error") != "access_denied" || u.Query().Get("state") != "opaque" { + t.Fatalf("error redirect = %q", got) + } + if _, err := AppendAuthorizationError("https://example.com/callback", "invalid_client", ""); err == nil { + t.Fatal("unsafe authorization error unexpectedly accepted") + } +} + +func TestNormalizeWebOriginRejectsPathAndQuery(t *testing.T) { + if got, err := NormalizeWebOrigin("https://Example.com/", false); err != nil || got != "https://example.com" { + t.Fatalf("NormalizeWebOrigin = %q,%v", got, err) + } + for _, raw := range []string{"https://example.com/path", "https://example.com/?x=1", "https://example.com/#x"} { + if _, err := NormalizeWebOrigin(raw, false); !errors.Is(err, domain.ErrTelegramLoginURLInvalid) { + t.Fatalf("NormalizeWebOrigin(%q) error = %v, want URL invalid", raw, err) + } + } +} + +func TestNormalizeLoopbackIPv6PreservesURLBrackets(t *testing.T) { + origin, err := NormalizeWebOrigin("http://[0:0:0:0:0:0:0:1]:80/", true) + if err != nil { + t.Fatal(err) + } + if origin != "http://[0:0:0:0:0:0:0:1]" { + t.Fatalf("origin=%q", origin) + } + redirect, domainName, err := NormalizeRedirectURI("http://[::1]/callback", true) + if err != nil { + t.Fatal(err) + } + if redirect != "http://[::1]/callback" || domainName != "::1" { + t.Fatalf("redirect=%q domain=%q", redirect, domainName) + } +} + +func TestPKCERFC7636Vector(t *testing.T) { + const verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + const want = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" + got, err := PKCEChallenge(verifier) + if err != nil || got != want { + t.Fatalf("PKCEChallenge = %q,%v, want %q,nil", got, err, want) + } +} + +func TestCodeSealerUsesAADAndRetiringKeys(t *testing.T) { + oldKey := make([]byte, 32) + newKey := make([]byte, 32) + oldKey[0], newKey[0] = 1, 2 + old, err := NewCodeSealer("old", map[string][]byte{"old": oldKey}) + if err != nil { + t.Fatal(err) + } + sealed, nonce, keyID, err := old.Seal("authorization-code", []byte("request-1")) + if err != nil { + t.Fatal(err) + } + rotated, err := NewCodeSealer("new", map[string][]byte{"old": oldKey, "new": newKey}) + if err != nil { + t.Fatal(err) + } + if got, err := rotated.Open(sealed, nonce, keyID, []byte("request-1")); err != nil || got != "authorization-code" { + t.Fatalf("Open after rotation = %q,%v", got, err) + } + if _, err := rotated.Open(sealed, nonce, keyID, []byte("request-2")); !errors.Is(err, domain.ErrTelegramLoginCodeInvalid) { + t.Fatalf("Open with wrong AAD error = %v", err) + } +} diff --git a/internal/botapi/inline.go b/internal/botapi/inline.go index 74d03bf5..56c98351 100644 --- a/internal/botapi/inline.go +++ b/internal/botapi/inline.go @@ -347,6 +347,9 @@ func markupButtonFromAPI(button apiInlineKeyboardButton) (domain.MarkupButton, e if button.CopyTextSet { constructors++ } + if button.LoginURLSet { + constructors++ + } if constructors != 1 { return domain.MarkupButton{}, errors.New("BUTTON_INVALID") } @@ -357,6 +360,13 @@ func markupButtonFromAPI(button apiInlineKeyboardButton) (domain.MarkupButton, e if button.URLSet { return domain.MarkupButton{Type: domain.MarkupButtonURL, Text: button.Text, URL: button.URL, Style: style, IconCustomEmojiID: icon}, nil } + if button.LoginURLSet { + return domain.MarkupButton{ + Type: domain.MarkupButtonLoginURL, Text: button.Text, URL: button.LoginURL, + ForwardText: button.LoginForwardText, LoginBotUsername: button.LoginBotUsername, + RequestWriteAccess: button.LoginRequestWriteAccess, Style: style, IconCustomEmojiID: icon, + }, nil + } if button.CallbackDataSet { if button.CallbackData == "" || len([]byte(button.CallbackData)) > domain.MaxCallbackDataLen { return domain.MarkupButton{}, errors.New("BUTTON_DATA_INVALID") @@ -689,23 +699,28 @@ type apiForceReply struct { } type apiInlineKeyboardButton struct { - Text string - URL string - URLSet bool - CallbackData string - CallbackDataSet bool - Style string - IconCustomEmojiID string - IconCustomEmojiIDSet bool - Unsupported bool - WebAppURL string - WebAppSet bool - SwitchInlineQuery string - SwitchInlineSet bool - SwitchInlineSamePeer bool - SwitchInlinePeerTypes []string - CopyText string - CopyTextSet bool + Text string + URL string + URLSet bool + CallbackData string + CallbackDataSet bool + Style string + IconCustomEmojiID string + IconCustomEmojiIDSet bool + Unsupported bool + WebAppURL string + WebAppSet bool + SwitchInlineQuery string + SwitchInlineSet bool + SwitchInlineSamePeer bool + SwitchInlinePeerTypes []string + CopyText string + CopyTextSet bool + LoginURL string + LoginForwardText string + LoginBotUsername string + LoginRequestWriteAccess bool + LoginURLSet bool } func (b *apiInlineKeyboardButton) UnmarshalJSON(data []byte) error { @@ -739,6 +754,20 @@ func (b *apiInlineKeyboardButton) UnmarshalJSON(data []byte) error { } b.WebAppURL = app.URL } + if raw, ok := fields["login_url"]; ok { + b.LoginURLSet = true + var login struct { + URL string `json:"url"` + ForwardText string `json:"forward_text"` + BotUsername string `json:"bot_username"` + RequestWriteAccess bool `json:"request_write_access"` + } + if json.Unmarshal(raw, &login) != nil { + return errors.New("invalid login url") + } + b.LoginURL, b.LoginForwardText = login.URL, login.ForwardText + b.LoginBotUsername, b.LoginRequestWriteAccess = login.BotUsername, login.RequestWriteAccess + } switchActions := 0 if raw, ok := fields["switch_inline_query"]; ok { switchActions++ @@ -807,7 +836,7 @@ func (b *apiInlineKeyboardButton) UnmarshalJSON(data []byte) error { } for key := range fields { switch key { - case "text", "url", "callback_data", "web_app", "switch_inline_query", "switch_inline_query_current_chat", "switch_inline_query_chosen_chat", "copy_text", "style", "icon_custom_emoji_id": + case "text", "url", "callback_data", "web_app", "login_url", "switch_inline_query", "switch_inline_query_current_chat", "switch_inline_query_chosen_chat", "copy_text", "style", "icon_custom_emoji_id": default: b.Unsupported = true } diff --git a/internal/botapi/projection.go b/internal/botapi/projection.go index 8048fb97..6c4c10ca 100644 --- a/internal/botapi/projection.go +++ b/internal/botapi/projection.go @@ -509,6 +509,18 @@ func apiReplyMarkup(markup *domain.MessageReplyMarkup) map[string]any { switch button.Type { case domain.MarkupButtonURL: item["url"] = button.URL + case domain.MarkupButtonLoginURL: + login := map[string]any{"url": button.URL} + if button.ForwardText != "" { + login["forward_text"] = button.ForwardText + } + if button.LoginBotUsername != "" { + login["bot_username"] = button.LoginBotUsername + } + if button.RequestWriteAccess { + login["request_write_access"] = true + } + item["login_url"] = login case domain.MarkupButtonCallback: item["callback_data"] = string(button.Data) case domain.MarkupButtonWebView: diff --git a/internal/botapi/server_test.go b/internal/botapi/server_test.go index e6afa8de..1bbc014a 100644 --- a/internal/botapi/server_test.go +++ b/internal/botapi/server_test.go @@ -886,6 +886,19 @@ func TestReplyMarkupFromAPIReplyKeyboardVariants(t *testing.T) { if err != nil || webApp == nil || webApp.Inline[0][0].Type != domain.MarkupButtonWebView { t.Fatalf("web_app inline button = %#v err=%v", webApp, err) } + login, err := replyMarkupFromAPI(json.RawMessage(`{"inline_keyboard":[[{"text":"Log in","login_url":{"url":"https://example.com/login","forward_text":"Open","bot_username":"auth_bot","request_write_access":true}}]]}`)) + if err != nil || login == nil { + t.Fatalf("login_url inline button = %#v err=%v", login, err) + } + loginButton := login.Inline[0][0] + if loginButton.Type != domain.MarkupButtonLoginURL || loginButton.URL != "https://example.com/login" || loginButton.ForwardText != "Open" || + loginButton.LoginBotUsername != "auth_bot" || !loginButton.RequestWriteAccess { + t.Fatalf("login_url button = %#v", loginButton) + } + projectedLogin := apiReplyMarkup(login)["inline_keyboard"].([][]map[string]any)[0][0]["login_url"].(map[string]any) + if projectedLogin["url"] != "https://example.com/login" || projectedLogin["bot_username"] != "auth_bot" || projectedLogin["request_write_access"] != true { + t.Fatalf("projected login_url = %#v", projectedLogin) + } } func TestReplyMarkupFromAPIPreservesSemanticButtonStyles(t *testing.T) { diff --git a/internal/config/config.go b/internal/config/config.go index ed9aacfc..3c08d7dd 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -4,6 +4,8 @@ package config import ( "bufio" "fmt" + "net" + "net/netip" "net/url" "os" "strconv" @@ -88,6 +90,22 @@ type Config struct { // PublicLinkWebAddr 是公开链接落地页监听地址;为空关闭。 // 生产应只监听 loopback,并由 nginx 将 /、/addstickers/、/addemoji/ 与 /addlist/ 反代到该地址。 PublicLinkWebAddr string + // TelegramLoginEnabled mounts the self-hosted Telegram Login/OIDC provider + // on PublicLinkWebAddr. Secrets are file-backed so they are not exposed in + // process listings or accidentally copied into tracked .env templates. + TelegramLoginEnabled bool + TelegramLoginIssuer string + TelegramLoginAllowLoopbackHTTP bool + TelegramLoginSigningKeysFile string + TelegramLoginCodeKeysFile string + TelegramLoginSecretPepperFile string + TelegramLoginRequestTTL time.Duration + TelegramLoginCodeTTL time.Duration + TelegramLoginIDTokenTTL time.Duration + TelegramLoginTrustedProxyCIDRs []string + TelegramLoginRetention time.Duration + TelegramLoginSweepInterval time.Duration + TelegramLoginSweepBatch int // Admin UI 独立进程配置项保留在统一配置中,cmd/telesrv-admin 也按同名 env 读取。 AdminUIAddr string AdminUIPassword string @@ -471,6 +489,19 @@ func Load() (Config, error) { PublicWebBaseURL: publicWebBaseURL, PublicAppName: publicAppName, PublicLinkWebAddr: envAllowEmptyOr("TELESRV_PUBLIC_LINK_WEB_ADDR", ""), + TelegramLoginEnabled: envBoolOr("TELESRV_TELEGRAM_LOGIN_ENABLE", false), + TelegramLoginIssuer: strings.TrimSuffix(envOr("TELESRV_TELEGRAM_LOGIN_ISSUER", publicBaseURL), "/"), + TelegramLoginAllowLoopbackHTTP: envBoolOr("TELESRV_TELEGRAM_LOGIN_ALLOW_LOOPBACK_HTTP", false), + TelegramLoginSigningKeysFile: envOr("TELESRV_TELEGRAM_LOGIN_SIGNING_KEYS_FILE", "data/telegram-login/signing-keys.json"), + TelegramLoginCodeKeysFile: envOr("TELESRV_TELEGRAM_LOGIN_CODE_KEYS_FILE", "data/telegram-login/code-keys.json"), + TelegramLoginSecretPepperFile: envOr("TELESRV_TELEGRAM_LOGIN_SECRET_PEPPER_FILE", "data/telegram-login/client-secret-pepper"), + TelegramLoginRequestTTL: envDurationOr("TELESRV_TELEGRAM_LOGIN_REQUEST_TTL", 5*time.Minute), + TelegramLoginCodeTTL: envDurationOr("TELESRV_TELEGRAM_LOGIN_CODE_TTL", 2*time.Minute), + TelegramLoginIDTokenTTL: envDurationOr("TELESRV_TELEGRAM_LOGIN_ID_TOKEN_TTL", time.Hour), + TelegramLoginTrustedProxyCIDRs: envListOr("TELESRV_TELEGRAM_LOGIN_TRUSTED_PROXY_CIDRS", nil), + TelegramLoginRetention: envDurationOr("TELESRV_TELEGRAM_LOGIN_RETENTION", 7*24*time.Hour), + TelegramLoginSweepInterval: envDurationOr("TELESRV_TELEGRAM_LOGIN_SWEEP_INTERVAL", 5*time.Minute), + TelegramLoginSweepBatch: envIntOr("TELESRV_TELEGRAM_LOGIN_SWEEP_BATCH", 500), AdminUIAddr: envOr("TELESRV_ADMIN_UI_ADDR", "127.0.0.1:2600"), AdminUIPassword: envOr("TELESRV_ADMIN_UI_PASSWORD", ""), AdminUIToken: envOr("TELESRV_ADMIN_UI_TOKEN", ""), @@ -629,9 +660,56 @@ func Load() (Config, error) { if err := validateStarGiftConfig(cfg); err != nil { return Config{}, err } + if err := validateTelegramLoginConfig(cfg); err != nil { + return Config{}, err + } return cfg, nil } +func validateTelegramLoginConfig(cfg Config) error { + if !cfg.TelegramLoginEnabled { + return nil + } + if strings.TrimSpace(cfg.PublicLinkWebAddr) == "" { + return fmt.Errorf("TELESRV_TELEGRAM_LOGIN_ENABLE requires TELESRV_PUBLIC_LINK_WEB_ADDR") + } + issuer, err := url.Parse(strings.TrimSpace(cfg.TelegramLoginIssuer)) + if err != nil || issuer.User != nil || issuer.Host == "" || issuer.RawQuery != "" || issuer.Fragment != "" || + (issuer.Path != "" && issuer.Path != "/") { + return fmt.Errorf("TELESRV_TELEGRAM_LOGIN_ISSUER must be an absolute origin URL") + } + switch issuer.Scheme { + case "https": + case "http": + host := issuer.Hostname() + ip := net.ParseIP(host) + if !cfg.TelegramLoginAllowLoopbackHTTP || (host != "localhost" && (ip == nil || !ip.IsLoopback())) { + return fmt.Errorf("TELESRV_TELEGRAM_LOGIN_ISSUER http is allowed only for explicit loopback development") + } + default: + return fmt.Errorf("TELESRV_TELEGRAM_LOGIN_ISSUER must use https") + } + if strings.TrimSpace(cfg.TelegramLoginSigningKeysFile) == "" || strings.TrimSpace(cfg.TelegramLoginCodeKeysFile) == "" || strings.TrimSpace(cfg.TelegramLoginSecretPepperFile) == "" { + return fmt.Errorf("TELESRV_TELEGRAM_LOGIN_* key and pepper files are required") + } + if cfg.TelegramLoginRequestTTL < time.Minute || cfg.TelegramLoginRequestTTL > 15*time.Minute || + cfg.TelegramLoginCodeTTL < 30*time.Second || cfg.TelegramLoginCodeTTL > 10*time.Minute || + cfg.TelegramLoginIDTokenTTL < time.Minute || cfg.TelegramLoginIDTokenTTL > 24*time.Hour { + return fmt.Errorf("TELESRV_TELEGRAM_LOGIN TTL values are outside their bounded ranges") + } + if cfg.TelegramLoginRetention < time.Hour || cfg.TelegramLoginRetention > 90*24*time.Hour || + cfg.TelegramLoginSweepInterval < 10*time.Second || cfg.TelegramLoginSweepInterval > time.Hour || + cfg.TelegramLoginSweepBatch <= 0 || cfg.TelegramLoginSweepBatch > 1000 { + return fmt.Errorf("TELESRV_TELEGRAM_LOGIN retention must be 1h..90d, sweep interval 10s..1h, and sweep batch 1..1000") + } + for _, raw := range cfg.TelegramLoginTrustedProxyCIDRs { + if _, err := netip.ParsePrefix(strings.TrimSpace(raw)); err != nil { + return fmt.Errorf("TELESRV_TELEGRAM_LOGIN_TRUSTED_PROXY_CIDRS contains invalid CIDR %q: %w", raw, err) + } + } + return nil +} + func validateStarGiftConfig(cfg Config) error { if cfg.StarGiftSweepInterval <= 0 || cfg.StarGiftSweepBatch <= 0 || cfg.StarGiftSweepBatch > 10000 { return fmt.Errorf("TELESRV_STARGIFT_SWEEP_INTERVAL must be positive and TELESRV_STARGIFT_SWEEP_BATCH must be 1..10000") diff --git a/internal/config/config_test.go b/internal/config/config_test.go index ebc5cf2f..b8e4c0f2 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -35,13 +35,13 @@ func TestLoadDefaultsAdvertiseIPToLoopback(t *testing.T) { func TestLoadUsesExplicitAdvertiseIP(t *testing.T) { disableDefaultConfigFile(t) - t.Setenv("TELESRV_ADVERTISE_IP", "192.0.2.10") + t.Setenv("TELESRV_ADVERTISE_IP", "10.172.61.102") cfg, err := Load() if err != nil { t.Fatalf("Load: %v", err) } - if cfg.AdvertiseIP != "192.0.2.10" { + if cfg.AdvertiseIP != "10.172.61.102" { t.Fatalf("AdvertiseIP = %q, want explicit env", cfg.AdvertiseIP) } } @@ -431,6 +431,83 @@ func TestLoadNormalizesLocalPublicBaseURL(t *testing.T) { } } +func TestLoadTelegramLoginConfig(t *testing.T) { + disableDefaultConfigFile(t) + t.Setenv("TELESRV_PUBLIC_LINK_WEB_ADDR", "127.0.0.1:2401") + t.Setenv("TELESRV_TELEGRAM_LOGIN_ENABLE", "true") + t.Setenv("TELESRV_TELEGRAM_LOGIN_ISSUER", "http://127.0.0.1:2401/") + t.Setenv("TELESRV_TELEGRAM_LOGIN_ALLOW_LOOPBACK_HTTP", "true") + t.Setenv("TELESRV_TELEGRAM_LOGIN_SIGNING_KEYS_FILE", "secrets/signing.json") + t.Setenv("TELESRV_TELEGRAM_LOGIN_CODE_KEYS_FILE", "secrets/codes.json") + t.Setenv("TELESRV_TELEGRAM_LOGIN_SECRET_PEPPER_FILE", "secrets/pepper") + t.Setenv("TELESRV_TELEGRAM_LOGIN_REQUEST_TTL", "7m") + t.Setenv("TELESRV_TELEGRAM_LOGIN_CODE_TTL", "90s") + t.Setenv("TELESRV_TELEGRAM_LOGIN_ID_TOKEN_TTL", "45m") + t.Setenv("TELESRV_TELEGRAM_LOGIN_TRUSTED_PROXY_CIDRS", "127.0.0.0/8,10.0.0.0/8") + t.Setenv("TELESRV_TELEGRAM_LOGIN_RETENTION", "48h") + t.Setenv("TELESRV_TELEGRAM_LOGIN_SWEEP_INTERVAL", "30s") + t.Setenv("TELESRV_TELEGRAM_LOGIN_SWEEP_BATCH", "73") + + cfg, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if !cfg.TelegramLoginEnabled || cfg.TelegramLoginIssuer != "http://127.0.0.1:2401" || !cfg.TelegramLoginAllowLoopbackHTTP { + t.Fatalf("telegram login endpoint config = enabled:%v issuer:%q loopback:%v", cfg.TelegramLoginEnabled, cfg.TelegramLoginIssuer, cfg.TelegramLoginAllowLoopbackHTTP) + } + if cfg.TelegramLoginSigningKeysFile != "secrets/signing.json" || cfg.TelegramLoginCodeKeysFile != "secrets/codes.json" || cfg.TelegramLoginSecretPepperFile != "secrets/pepper" { + t.Fatalf("telegram login secret files = %q / %q / %q", cfg.TelegramLoginSigningKeysFile, cfg.TelegramLoginCodeKeysFile, cfg.TelegramLoginSecretPepperFile) + } + if cfg.TelegramLoginRequestTTL != 7*time.Minute || cfg.TelegramLoginCodeTTL != 90*time.Second || cfg.TelegramLoginIDTokenTTL != 45*time.Minute || + cfg.TelegramLoginRetention != 48*time.Hour || cfg.TelegramLoginSweepInterval != 30*time.Second || cfg.TelegramLoginSweepBatch != 73 { + t.Fatalf("telegram login durations/batch = %v / %v / %v / %v / %v / %d", cfg.TelegramLoginRequestTTL, cfg.TelegramLoginCodeTTL, + cfg.TelegramLoginIDTokenTTL, cfg.TelegramLoginRetention, cfg.TelegramLoginSweepInterval, cfg.TelegramLoginSweepBatch) + } + if len(cfg.TelegramLoginTrustedProxyCIDRs) != 2 || cfg.TelegramLoginTrustedProxyCIDRs[1] != "10.0.0.0/8" { + t.Fatalf("trusted proxy CIDRs = %#v", cfg.TelegramLoginTrustedProxyCIDRs) + } +} + +func TestValidateTelegramLoginConfigRejectsUnsafeOrUnboundedSettings(t *testing.T) { + valid := Config{ + TelegramLoginEnabled: true, PublicLinkWebAddr: "127.0.0.1:2401", TelegramLoginIssuer: "https://login.example.test", + TelegramLoginSigningKeysFile: "signing.json", TelegramLoginCodeKeysFile: "codes.json", TelegramLoginSecretPepperFile: "pepper", + TelegramLoginRequestTTL: 5 * time.Minute, TelegramLoginCodeTTL: 2 * time.Minute, TelegramLoginIDTokenTTL: time.Hour, + TelegramLoginRetention: 7 * 24 * time.Hour, TelegramLoginSweepInterval: 5 * time.Minute, TelegramLoginSweepBatch: 500, + } + if err := validateTelegramLoginConfig(valid); err != nil { + t.Fatalf("valid config: %v", err) + } + tests := []struct { + name string + mutate func(*Config) + }{ + {name: "missing listener", mutate: func(c *Config) { c.PublicLinkWebAddr = "" }}, + {name: "issuer path", mutate: func(c *Config) { c.TelegramLoginIssuer = "https://login.example.test/oauth" }}, + {name: "public http", mutate: func(c *Config) { + c.TelegramLoginIssuer = "http://login.example.test" + c.TelegramLoginAllowLoopbackHTTP = true + }}, + {name: "loopback http disabled", mutate: func(c *Config) { c.TelegramLoginIssuer = "http://127.0.0.1:2401" }}, + {name: "missing key file", mutate: func(c *Config) { c.TelegramLoginSigningKeysFile = "" }}, + {name: "request ttl too long", mutate: func(c *Config) { c.TelegramLoginRequestTTL = 16 * time.Minute }}, + {name: "code ttl too short", mutate: func(c *Config) { c.TelegramLoginCodeTTL = 29 * time.Second }}, + {name: "id token ttl too long", mutate: func(c *Config) { c.TelegramLoginIDTokenTTL = 25 * time.Hour }}, + {name: "retention too short", mutate: func(c *Config) { c.TelegramLoginRetention = 59 * time.Minute }}, + {name: "sweep unbounded", mutate: func(c *Config) { c.TelegramLoginSweepBatch = 1001 }}, + {name: "invalid proxy CIDR", mutate: func(c *Config) { c.TelegramLoginTrustedProxyCIDRs = []string{"10.0.0.0/33"} }}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + cfg := valid + tc.mutate(&cfg) + if err := validateTelegramLoginConfig(cfg); err == nil { + t.Fatal("unsafe Telegram Login config was accepted") + } + }) + } +} + func TestLoadRejectsInvalidPublicBaseURL(t *testing.T) { disableDefaultConfigFile(t) t.Setenv("TELESRV_PUBLIC_BASE_URL", "https://links.example.test/root?tenant=one") diff --git a/internal/domain/message_markup.go b/internal/domain/message_markup.go index 35c992d9..9196d574 100644 --- a/internal/domain/message_markup.go +++ b/internal/domain/message_markup.go @@ -2,6 +2,7 @@ package domain import ( "errors" + "net" "net/url" "strings" "unicode/utf8" @@ -51,7 +52,12 @@ const ( // MarkupButtonCallback 是 keyboardButtonCallback(点击触发 getBotCallbackAnswer)。 MarkupButtonCallback MarkupButtonType = "callback" // MarkupButtonURL 是 keyboardButtonUrl(点击打开链接)。 - MarkupButtonURL MarkupButtonType = "url" + MarkupButtonURL MarkupButtonType = "url" + // MarkupButtonLoginURL is Bot API login_url / inputKeyboardButtonUrlAuth. + // The target bot is resolved and the linked origin is verified before the + // message is persisted; ButtonID is the stable flattened keyboard index + // returned to clients as keyboardButtonUrlAuth.button_id. + MarkupButtonLoginURL MarkupButtonType = "login_url" MarkupButtonRequestPhone MarkupButtonType = "request_phone" MarkupButtonRequestLocation MarkupButtonType = "request_location" MarkupButtonRequestPoll MarkupButtonType = "request_poll" @@ -122,6 +128,13 @@ type MarkupButton struct { Data []byte `json:"data,omitempty"` // URL 仅 url 使用。 URL string `json:"url,omitempty"` + // Login URL-only fields. LoginBotUserID=0 means the sending bot until the + // RPC/Bot API edge resolves it. LoginBotUsername is input-only and must be + // cleared before persistence. + ForwardText string `json:"forward_text,omitempty"` + LoginBotUserID int64 `json:"login_bot_user_id,omitempty"` + LoginBotUsername string `json:"login_bot_username,omitempty"` + RequestWriteAccess bool `json:"request_write_access,omitempty"` // RequiresPassword 仅 callback 使用(keyboardButtonCallback.requires_password, // 2FA SRP 校验 P3 stub)。 RequiresPassword bool `json:"requires_password,omitempty"` @@ -343,6 +356,14 @@ func validateMarkupButton(b MarkupButton, replyKeyboard bool) error { if err := validateButtonURL(b.URL); err != nil { return err } + case MarkupButtonLoginURL: + if err := validateLoginButtonURL(b.URL); err != nil { + return err + } + if b.ButtonID < 0 || b.LoginBotUserID < 0 || utf8.RuneCountInString(b.ForwardText) > MaxReplyKeyboardButtonTextLen || + utf8.RuneCountInString(b.LoginBotUsername) > 64 { + return ErrButtonInvalid + } case MarkupButtonWebView: if err := validateButtonURL(b.URL); err != nil { return err @@ -374,6 +395,33 @@ func validateButtonURL(raw string) error { return nil } +// validateLoginButtonURL performs only the protocol-shape validation shared by +// Bot API and MTProto input buttons. The Telegram Login service remains the +// authority for the deployment policy: it rejects loopback HTTP unless the +// explicit development switch is enabled and the exact origin is registered. +func validateLoginButtonURL(raw string) error { + raw = strings.TrimSpace(raw) + if raw == "" || len(raw) > MaxBotMenuButtonURLLen { + return ErrButtonURLInvalid + } + u, err := url.Parse(raw) + if err != nil || u.Host == "" || u.User != nil { + return ErrButtonURLInvalid + } + if u.Scheme == "https" { + return nil + } + if u.Scheme != "http" { + return ErrButtonURLInvalid + } + host := strings.ToLower(u.Hostname()) + ip := net.ParseIP(host) + if host != "localhost" && (ip == nil || !ip.IsLoopback()) { + return ErrButtonURLInvalid + } + return nil +} + // BotCallbackAnswer 是 bot 对一次 callback query 的应答(setBotCallbackAnswer → // 解挂等待中的 getBotCallbackAnswer)。 type BotCallbackAnswer struct { diff --git a/internal/domain/message_markup_test.go b/internal/domain/message_markup_test.go index 38444c4d..8ff348ed 100644 --- a/internal/domain/message_markup_test.go +++ b/internal/domain/message_markup_test.go @@ -26,6 +26,10 @@ func TestValidateReplyMarkup(t *testing.T) { {"url http bad", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: MarkupButtonURL, Text: "go", URL: "http://example.com"}}}}, ErrButtonURLInvalid}, {"url javascript bad", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: MarkupButtonURL, Text: "go", URL: "javascript:alert(1)"}}}}, ErrButtonURLInvalid}, {"url empty bad", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: MarkupButtonURL, Text: "go", URL: ""}}}}, ErrButtonURLInvalid}, + {"login url loopback http ok", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: MarkupButtonLoginURL, Text: "login", URL: "http://127.0.0.1:8080/login"}}}}, nil}, + {"login url localhost http ok", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: MarkupButtonLoginURL, Text: "login", URL: "http://localhost:8080/login"}}}}, nil}, + {"login url public http bad", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: MarkupButtonLoginURL, Text: "login", URL: "http://example.com/login"}}}}, ErrButtonURLInvalid}, + {"login url credentials bad", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: MarkupButtonLoginURL, Text: "login", URL: "https://user@example.com/login"}}}}, ErrButtonURLInvalid}, {"unknown type bad", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: "rainbow", Text: "x"}}}}, ErrButtonTypeInvalid}, {"reply keyboard ok", &MessageReplyMarkup{Type: MessageReplyMarkupKeyboard, Keyboard: [][]MarkupButton{{{Type: MarkupButtonText, Text: "Help"}}}, Resize: true, Persistent: true, Placeholder: "Choose"}, nil}, {"reply keyboard semantic style ok", &MessageReplyMarkup{Type: MessageReplyMarkupKeyboard, Keyboard: [][]MarkupButton{{{Type: MarkupButtonText, Text: "Delete", Style: MarkupButtonStyleDanger, IconCustomEmojiID: 123}}}}, nil}, diff --git a/internal/domain/telegram_login.go b/internal/domain/telegram_login.go new file mode 100644 index 00000000..bddab136 --- /dev/null +++ b/internal/domain/telegram_login.go @@ -0,0 +1,506 @@ +package domain + +import ( + "errors" + "slices" + "strings" + "time" + "unicode/utf8" +) + +var ( + ErrTelegramLoginClientInvalid = errors.New("telegram login client invalid") + ErrTelegramLoginClientDisabled = errors.New("telegram login client disabled") + ErrTelegramLoginURLInvalid = errors.New("telegram login url invalid") + ErrTelegramLoginRequestInvalid = errors.New("telegram login request invalid") + ErrTelegramLoginRequestExpired = errors.New("telegram login request expired") + ErrTelegramLoginRequestConflict = errors.New("telegram login request conflict") + ErrTelegramLoginMatchCodeInvalid = errors.New("telegram login match code invalid") + ErrTelegramLoginScopeInvalid = errors.New("telegram login scope invalid") + ErrTelegramLoginCodeInvalid = errors.New("telegram login code invalid") + ErrTelegramLoginCodeConsumed = errors.New("telegram login code consumed") + ErrTelegramLoginWebAuthHashInvalid = errors.New("telegram login web authorization hash invalid") + ErrTelegramLoginRedirectNotAllowed = errors.New("telegram login redirect not allowed") + ErrTelegramLoginOriginNotAllowed = errors.New("telegram login origin not allowed") + ErrTelegramLoginSecretInvalid = errors.New("telegram login client secret invalid") + ErrTelegramLoginPKCEInvalid = errors.New("telegram login pkce invalid") + ErrTelegramLoginAuthorizationsTooMany = errors.New("telegram login authorizations too many") +) + +const MaxTelegramLoginWebAuthorizations = 1000 + +type TelegramLoginSigningAlgorithm string + +const ( + TelegramLoginSigningRS256 TelegramLoginSigningAlgorithm = "RS256" + TelegramLoginSigningES256 TelegramLoginSigningAlgorithm = "ES256" + TelegramLoginSigningEdDSA TelegramLoginSigningAlgorithm = "EdDSA" + TelegramLoginSigningES256K TelegramLoginSigningAlgorithm = "ES256K" +) + +func (a TelegramLoginSigningAlgorithm) Valid() bool { + switch a { + case TelegramLoginSigningRS256, TelegramLoginSigningES256, TelegramLoginSigningEdDSA, TelegramLoginSigningES256K: + return true + default: + return false + } +} + +type TelegramLoginScope string + +const ( + TelegramLoginScopeOpenID TelegramLoginScope = "openid" + TelegramLoginScopeProfile TelegramLoginScope = "profile" + TelegramLoginScopePhone TelegramLoginScope = "phone" + TelegramLoginScopeBotAccess TelegramLoginScope = "telegram:bot_access" +) + +func (s TelegramLoginScope) Valid() bool { + switch s { + case TelegramLoginScopeOpenID, TelegramLoginScopeProfile, TelegramLoginScopePhone, TelegramLoginScopeBotAccess: + return true + default: + return false + } +} + +type TelegramLoginClient struct { + BotUserID int64 + ClientID string + SecretHash []byte + SecretVersion int64 + SigningAlgorithm TelegramLoginSigningAlgorithm + Enabled bool + CreatedAt time.Time + UpdatedAt time.Time +} + +func (c TelegramLoginClient) Clone() TelegramLoginClient { + out := c + out.SecretHash = append([]byte(nil), c.SecretHash...) + return out +} + +func (c TelegramLoginClient) Validate() error { + if c.BotUserID <= 0 || c.ClientID == "" || len(c.SecretHash) != 32 || c.SecretVersion <= 0 || !c.SigningAlgorithm.Valid() { + return ErrTelegramLoginClientInvalid + } + return nil +} + +type TelegramLoginAllowedURLKind string + +const ( + TelegramLoginAllowedWebOrigin TelegramLoginAllowedURLKind = "web_origin" + TelegramLoginAllowedRedirectURI TelegramLoginAllowedURLKind = "redirect_uri" +) + +type TelegramLoginAllowedURL struct { + ID int64 + BotUserID int64 + Kind TelegramLoginAllowedURLKind + NormalizedURL string + CreatedAt time.Time +} + +type TelegramLoginNativePlatform string + +const ( + TelegramLoginNativeIOS TelegramLoginNativePlatform = "ios" + TelegramLoginNativeAndroid TelegramLoginNativePlatform = "android" +) + +type TelegramLoginNativeApp struct { + ID int64 + BotUserID int64 + Platform TelegramLoginNativePlatform + ApplicationID string + // VerificationID is the 10-character Apple Team ID on iOS and the + // normalized 64-hex SHA-256 signing-certificate fingerprint on Android. + VerificationID string + CallbackURI string + VerifiedDisplayName string + Enabled bool + CreatedAt time.Time + UpdatedAt time.Time +} + +const MaxTelegramLoginNativeApps = 20 + +func (p TelegramLoginNativePlatform) Valid() bool { + return p == TelegramLoginNativeIOS || p == TelegramLoginNativeAndroid +} + +func (a TelegramLoginNativeApp) Validate() error { + if a.BotUserID <= 0 || !a.Platform.Valid() || a.ApplicationID == "" || len(a.ApplicationID) > 255 || + a.VerificationID == "" || a.CallbackURI == "" || len(a.CallbackURI) > 4096 || + a.VerifiedDisplayName == "" || len(a.VerifiedDisplayName) > 128 || + a.CreatedAt.IsZero() || a.UpdatedAt.IsZero() { + return ErrTelegramLoginClientInvalid + } + return nil +} + +type TelegramLoginRequestSource string + +const ( + TelegramLoginRequestWeb TelegramLoginRequestSource = "web" + TelegramLoginRequestJavaScript TelegramLoginRequestSource = "javascript" + TelegramLoginRequestNative TelegramLoginRequestSource = "native" + TelegramLoginRequestMiniApp TelegramLoginRequestSource = "mini_app" + TelegramLoginRequestMessageButton TelegramLoginRequestSource = "message_button" +) + +type TelegramLoginRequestState string + +const ( + TelegramLoginRequestPending TelegramLoginRequestState = "pending" + TelegramLoginRequestApproved TelegramLoginRequestState = "approved" + TelegramLoginRequestDeclined TelegramLoginRequestState = "declined" + TelegramLoginRequestExpired TelegramLoginRequestState = "expired" +) + +func (s TelegramLoginRequestState) Terminal() bool { + return s == TelegramLoginRequestApproved || s == TelegramLoginRequestDeclined || s == TelegramLoginRequestExpired +} + +func CanTransitionTelegramLoginRequest(from, to TelegramLoginRequestState) bool { + if from != TelegramLoginRequestPending { + return false + } + return to == TelegramLoginRequestApproved || to == TelegramLoginRequestDeclined || to == TelegramLoginRequestExpired +} + +type TelegramLoginRequest struct { + ID int64 + RequestTokenHash []byte + BrowserTokenHash []byte + BotUserID int64 + ClientID string + SigningAlgorithm TelegramLoginSigningAlgorithm + Source TelegramLoginRequestSource + ResponseType string + RedirectURI string + Origin string + Domain string + Scopes []TelegramLoginScope + State string + Nonce string + CodeChallenge string + CodeChallengeMethod string + Browser string + Platform string + IP string + Region string + InAppOrigin string + IsApp bool + VerifiedAppName string + MatchCodes []string + MatchCode string + MatchCodesFirst bool + UserIDHint int64 + PeerType PeerType + PeerID int64 + MessageID int + ButtonID int + Status TelegramLoginRequestState + AuthorizedUserID int64 + ProfileName string + GivenName string + FamilyName string + PreferredUsername string + Picture string + PhoneNumber string + WriteAllowed bool + PhoneShared bool + CreatedAt time.Time + ExpiresAt time.Time + ApprovedAt time.Time + DeclinedAt time.Time +} + +func (r TelegramLoginRequest) Clone() TelegramLoginRequest { + out := r + out.RequestTokenHash = append([]byte(nil), r.RequestTokenHash...) + out.BrowserTokenHash = append([]byte(nil), r.BrowserTokenHash...) + out.Scopes = append([]TelegramLoginScope(nil), r.Scopes...) + out.MatchCodes = append([]string(nil), r.MatchCodes...) + return out +} + +func (r TelegramLoginRequest) Requests(scope TelegramLoginScope) bool { + return slices.Contains(r.Scopes, scope) +} + +func (r TelegramLoginRequest) Validate() error { + if len(r.RequestTokenHash) != 32 || len(r.BrowserTokenHash) != 32 || r.BotUserID <= 0 || r.ClientID == "" || r.ClientID != strings.TrimSpace(r.ClientID) || r.RedirectURI == "" || r.Domain == "" { + return ErrTelegramLoginRequestInvalid + } + if !r.SigningAlgorithm.Valid() || !r.Source.Valid() || (r.ResponseType != "code" && r.ResponseType != "post_message" && r.ResponseType != "legacy_url") || + r.Status != TelegramLoginRequestPending || r.CreatedAt.IsZero() || !r.ExpiresAt.After(r.CreatedAt) { + return ErrTelegramLoginRequestInvalid + } + switch r.Source { + case TelegramLoginRequestWeb: + if r.ResponseType != "code" { + return ErrTelegramLoginRequestInvalid + } + case TelegramLoginRequestJavaScript: + if r.ResponseType != "post_message" { + return ErrTelegramLoginRequestInvalid + } + case TelegramLoginRequestNative: + if r.ResponseType != "code" || !r.IsApp || r.VerifiedAppName == "" || r.Origin != "" { + return ErrTelegramLoginRequestInvalid + } + case TelegramLoginRequestMiniApp: + if r.ResponseType != "post_message" { + return ErrTelegramLoginRequestInvalid + } + case TelegramLoginRequestMessageButton: + if r.ResponseType != "legacy_url" { + return ErrTelegramLoginRequestInvalid + } + default: + return ErrTelegramLoginRequestInvalid + } + if r.Source != TelegramLoginRequestNative && (r.IsApp || r.VerifiedAppName != "" || r.Origin == "") { + return ErrTelegramLoginRequestInvalid + } + if r.AuthorizedUserID != 0 || r.ProfileName != "" || r.GivenName != "" || r.FamilyName != "" || + r.PreferredUsername != "" || r.Picture != "" || r.PhoneNumber != "" || r.WriteAllowed || r.PhoneShared || + !r.ApprovedAt.IsZero() || !r.DeclinedAt.IsZero() { + return ErrTelegramLoginRequestInvalid + } + if len(r.RedirectURI) > 4096 || len(r.Origin) > 4096 || len(r.Domain) > 255 || len(r.InAppOrigin) > 4096 || + len(r.State) > 2048 || len(r.Nonce) > 1024 || len(r.Browser) == 0 || len(r.Browser) > 255 || + len(r.Platform) == 0 || len(r.Platform) > 255 || len(r.IP) == 0 || len(r.IP) > 128 || + len(r.Region) == 0 || len(r.Region) > 255 || len(r.VerifiedAppName) > 128 || r.UserIDHint < 0 || + r.PeerID < 0 || r.MessageID < 0 || r.ButtonID < 0 || len(r.MatchCodes) > 8 { + return ErrTelegramLoginRequestInvalid + } + if r.ResponseType == "legacy_url" { + if r.Source != TelegramLoginRequestMessageButton || r.PeerID <= 0 || r.MessageID <= 0 || + (r.PeerType != PeerTypeUser && r.PeerType != PeerTypeChannel) || r.CodeChallenge != "" || r.CodeChallengeMethod != "" || + len(r.MatchCodes) != 0 || r.MatchCode != "" || r.MatchCodesFirst { + return ErrTelegramLoginRequestInvalid + } + if !slices.Contains(r.Scopes, TelegramLoginScopeOpenID) || !slices.Contains(r.Scopes, TelegramLoginScopeProfile) { + return ErrTelegramLoginScopeInvalid + } + seen := make(map[TelegramLoginScope]struct{}, len(r.Scopes)) + for _, scope := range r.Scopes { + if !scope.Valid() || scope == TelegramLoginScopePhone { + return ErrTelegramLoginScopeInvalid + } + if _, duplicate := seen[scope]; duplicate { + return ErrTelegramLoginScopeInvalid + } + seen[scope] = struct{}{} + } + } else if r.ResponseType == "code" { + if err := ValidateTelegramLoginScopes(r.Scopes, r.SigningAlgorithm); err != nil { + return err + } + if r.CodeChallengeMethod != "S256" || r.CodeChallenge == "" { + return ErrTelegramLoginPKCEInvalid + } + } else { + if err := ValidateTelegramLoginScopes(r.Scopes, r.SigningAlgorithm); err != nil { + return err + } + // Telegram's official JavaScript SDK returns an ID token directly and + // therefore sends no authorization-code PKCE parameters. Accept a PKCE + // pair for generic callers, but never a partial pair. + if r.CodeChallenge == "" && r.CodeChallengeMethod == "" { + // Official post_message/Mini App shape. + } else if r.CodeChallengeMethod != "S256" || r.CodeChallenge == "" { + return ErrTelegramLoginPKCEInvalid + } + } + if r.Source == TelegramLoginRequestMiniApp { + if r.ResponseType != "post_message" || r.InAppOrigin == "" || r.Origin != r.InAppOrigin { + return ErrTelegramLoginRequestInvalid + } + } else if r.InAppOrigin != "" { + return ErrTelegramLoginRequestInvalid + } + if r.MatchCodesFirst && len(r.MatchCodes) == 0 { + return ErrTelegramLoginRequestInvalid + } + if len(r.MatchCodes) > 0 && (r.MatchCode == "" || !slices.Contains(r.MatchCodes, r.MatchCode)) { + return ErrTelegramLoginRequestInvalid + } + return nil +} + +// TelegramLoginMessageButtonAuthorization is the domain-only input for the +// legacy login_url consent path. BotToken is used transiently to produce the +// official HMAC response and is never persisted in the login aggregate. +type TelegramLoginMessageButtonAuthorization struct { + UserID int64 + BotUserID int64 + BotToken string + URL string + RequestWriteAccess bool + WriteAllowed bool + Peer Peer + MessageID int + ButtonID int + Browser string + Platform string + IP string + Region string + Identity TelegramLoginIdentitySnapshot +} + +type TelegramLoginMessageButtonResult struct { + URL string + Request TelegramLoginRequest + WebAuthorization TelegramLoginWebAuthorization +} + +func (s TelegramLoginRequestSource) Valid() bool { + switch s { + case TelegramLoginRequestWeb, TelegramLoginRequestJavaScript, TelegramLoginRequestNative, + TelegramLoginRequestMiniApp, TelegramLoginRequestMessageButton: + return true + default: + return false + } +} + +// TelegramLoginIdentitySnapshot is the immutable identity presented on the +// approval screen and later signed into the ID token. It is written together +// with the pending->approved transition so a profile/phone mutation between +// approval and code exchange cannot change what the relying party receives. +type TelegramLoginIdentitySnapshot struct { + UserID int64 + Name string + GivenName string + FamilyName string + PreferredUsername string + Picture string + PhoneNumber string +} + +func (s TelegramLoginIdentitySnapshot) Sanitized(includeProfile, includePhone bool) (TelegramLoginIdentitySnapshot, error) { + if s.UserID <= 0 { + return TelegramLoginIdentitySnapshot{}, ErrTelegramLoginRequestInvalid + } + out := TelegramLoginIdentitySnapshot{UserID: s.UserID} + if includeProfile { + out.Name = strings.TrimSpace(s.Name) + out.GivenName = strings.TrimSpace(s.GivenName) + out.FamilyName = strings.TrimSpace(s.FamilyName) + out.PreferredUsername = strings.TrimSpace(s.PreferredUsername) + out.Picture = strings.TrimSpace(s.Picture) + if out.Name == "" || out.GivenName == "" { + return TelegramLoginIdentitySnapshot{}, ErrTelegramLoginRequestInvalid + } + } + if includePhone { + out.PhoneNumber = NormalizePhone(s.PhoneNumber) + if !ValidPhone(out.PhoneNumber) { + return TelegramLoginIdentitySnapshot{}, ErrPhoneNumberInvalid + } + } + if !boundedUTF8(out.Name, 255) || !boundedUTF8(out.GivenName, 255) || !boundedUTF8(out.FamilyName, 255) || + !boundedUTF8(out.PreferredUsername, 64) || !boundedUTF8(out.Picture, 4096) || len(out.PhoneNumber) > 32 { + return TelegramLoginIdentitySnapshot{}, ErrTelegramLoginRequestInvalid + } + return out, nil +} + +func boundedUTF8(value string, maxBytes int) bool { + return utf8.ValidString(value) && len(value) <= maxBytes +} + +func ValidateTelegramLoginScopes(scopes []TelegramLoginScope, alg TelegramLoginSigningAlgorithm) error { + if !alg.Valid() || len(scopes) == 0 || !slices.Contains(scopes, TelegramLoginScopeOpenID) { + return ErrTelegramLoginScopeInvalid + } + seen := make(map[TelegramLoginScope]struct{}, len(scopes)) + for _, scope := range scopes { + if !scope.Valid() { + return ErrTelegramLoginScopeInvalid + } + if _, duplicate := seen[scope]; duplicate { + return ErrTelegramLoginScopeInvalid + } + seen[scope] = struct{}{} + } + if alg == TelegramLoginSigningEdDSA || alg == TelegramLoginSigningES256K { + if len(scopes) != 1 || scopes[0] != TelegramLoginScopeOpenID { + return ErrTelegramLoginScopeInvalid + } + } + return nil +} + +type TelegramLoginApproval struct { + RequestID int64 + Identity TelegramLoginIdentitySnapshot + WriteAllowed bool + PhoneShared bool + MatchCode string + ApprovedAt time.Time +} + +type TelegramLoginAuthorizationCode struct { + ID int64 + RequestID int64 + CodeHash []byte + SealedCode []byte + SealNonce []byte + SealKeyID string + IssuedAt time.Time + ExpiresAt time.Time + ConsumedAt time.Time +} + +// TelegramLoginCodeExchange carries the values already normalized/hashed by +// the application service. The durable store compares them again while the +// code/request/client rows are locked, closing redirect, PKCE and secret- +// rotation TOCTOU gaps between HTTP validation and one-time consumption. +type TelegramLoginCodeExchange struct { + CodeHash []byte + ClientID string + ClientSecretVersion int64 + RedirectURI string + CodeChallenge string + Now time.Time +} + +func (c TelegramLoginAuthorizationCode) Clone() TelegramLoginAuthorizationCode { + out := c + out.CodeHash = append([]byte(nil), c.CodeHash...) + out.SealedCode = append([]byte(nil), c.SealedCode...) + out.SealNonce = append([]byte(nil), c.SealNonce...) + return out +} + +type TelegramLoginWebAuthorization struct { + Hash int64 + RequestID int64 + UserID int64 + BotUserID int64 + Domain string + Browser string + Platform string + IP string + Region string + Scopes []TelegramLoginScope + PhoneShared bool + BotAccessGranted bool + CreatedAt time.Time + LastActiveAt time.Time + RevokedAt time.Time +} + +func (a TelegramLoginWebAuthorization) Clone() TelegramLoginWebAuthorization { + out := a + out.Scopes = append([]TelegramLoginScope(nil), a.Scopes...) + return out +} diff --git a/internal/domain/telegram_login_test.go b/internal/domain/telegram_login_test.go new file mode 100644 index 00000000..792a8dfc --- /dev/null +++ b/internal/domain/telegram_login_test.go @@ -0,0 +1,112 @@ +package domain + +import ( + "strings" + "testing" + "time" +) + +func TestValidateTelegramLoginScopes(t *testing.T) { + tests := []struct { + name string + scopes []TelegramLoginScope + alg TelegramLoginSigningAlgorithm + valid bool + }{ + {name: "rs profile phone", scopes: []TelegramLoginScope{TelegramLoginScopeOpenID, TelegramLoginScopeProfile, TelegramLoginScopePhone}, alg: TelegramLoginSigningRS256, valid: true}, + {name: "missing openid", scopes: []TelegramLoginScope{TelegramLoginScopeProfile}, alg: TelegramLoginSigningRS256}, + {name: "duplicate", scopes: []TelegramLoginScope{TelegramLoginScopeOpenID, TelegramLoginScopeOpenID}, alg: TelegramLoginSigningRS256}, + {name: "unknown", scopes: []TelegramLoginScope{TelegramLoginScopeOpenID, "admin"}, alg: TelegramLoginSigningRS256}, + {name: "eddsa openid", scopes: []TelegramLoginScope{TelegramLoginScopeOpenID}, alg: TelegramLoginSigningEdDSA, valid: true}, + {name: "eddsa profile forbidden", scopes: []TelegramLoginScope{TelegramLoginScopeOpenID, TelegramLoginScopeProfile}, alg: TelegramLoginSigningEdDSA}, + {name: "es256k phone forbidden", scopes: []TelegramLoginScope{TelegramLoginScopeOpenID, TelegramLoginScopePhone}, alg: TelegramLoginSigningES256K}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := ValidateTelegramLoginScopes(test.scopes, test.alg) + if (err == nil) != test.valid { + t.Fatalf("ValidateTelegramLoginScopes() error = %v, valid = %v", err, test.valid) + } + }) + } +} + +func TestTelegramLoginRequestTransitions(t *testing.T) { + for _, terminal := range []TelegramLoginRequestState{ + TelegramLoginRequestApproved, + TelegramLoginRequestDeclined, + TelegramLoginRequestExpired, + } { + if !CanTransitionTelegramLoginRequest(TelegramLoginRequestPending, terminal) { + t.Fatalf("pending -> %s must be valid", terminal) + } + if CanTransitionTelegramLoginRequest(terminal, TelegramLoginRequestPending) { + t.Fatalf("%s -> pending must be forbidden", terminal) + } + } + if CanTransitionTelegramLoginRequest(TelegramLoginRequestApproved, TelegramLoginRequestDeclined) { + t.Fatal("approved -> declined must be forbidden") + } +} + +func TestTelegramLoginRequestSourceShapeMatrix(t *testing.T) { + now := time.Unix(1_780_000_000, 0).UTC() + base := TelegramLoginRequest{ + RequestTokenHash: make([]byte, 32), BrowserTokenHash: make([]byte, 32), + BotUserID: 9001, ClientID: "9001", SigningAlgorithm: TelegramLoginSigningRS256, + Source: TelegramLoginRequestWeb, ResponseType: "code", RedirectURI: "https://rp.example/callback", + Origin: "https://rp.example", Domain: "rp.example", Scopes: []TelegramLoginScope{TelegramLoginScopeOpenID}, + CodeChallenge: strings.Repeat("A", 43), CodeChallengeMethod: "S256", + Browser: "Firefox", Platform: "Windows", IP: "192.0.2.1", Region: "Test", + Status: TelegramLoginRequestPending, CreatedAt: now, ExpiresAt: now.Add(5 * time.Minute), + } + if err := base.Validate(); err != nil { + t.Fatalf("valid web request: %v", err) + } + invalid := []struct { + name string + mutate func(*TelegramLoginRequest) + }{ + {name: "web post message", mutate: func(r *TelegramLoginRequest) { + r.ResponseType = "post_message" + r.CodeChallenge = "" + r.CodeChallengeMethod = "" + }}, + {name: "javascript code", mutate: func(r *TelegramLoginRequest) { r.Source = TelegramLoginRequestJavaScript }}, + {name: "message button code", mutate: func(r *TelegramLoginRequest) { r.Source = TelegramLoginRequestMessageButton }}, + {name: "web app flag", mutate: func(r *TelegramLoginRequest) { r.IsApp = true; r.VerifiedAppName = "Forged" }}, + {name: "web missing origin", mutate: func(r *TelegramLoginRequest) { r.Origin = "" }}, + } + for _, tc := range invalid { + t.Run(tc.name, func(t *testing.T) { + request := base.Clone() + tc.mutate(&request) + if err := request.Validate(); err == nil { + t.Fatal("forbidden source shape was accepted") + } + }) + } + + native := base.Clone() + native.Source, native.Origin, native.Domain = TelegramLoginRequestNative, "", "dev.bedolaga.demo" + native.IsApp, native.VerifiedAppName = true, "Bedolaga" + if err := native.Validate(); err != nil { + t.Fatalf("valid native request: %v", err) + } + native.IsApp = false + if err := native.Validate(); err == nil { + t.Fatal("native request without verified app state was accepted") + } + + mini := base.Clone() + mini.Source, mini.ResponseType = TelegramLoginRequestMiniApp, "post_message" + mini.CodeChallenge, mini.CodeChallengeMethod = "", "" + mini.RedirectURI, mini.InAppOrigin = "https://rp.example/", mini.Origin + if err := mini.Validate(); err != nil { + t.Fatalf("valid Mini App request: %v", err) + } + mini.InAppOrigin = "https://other.example" + if err := mini.Validate(); err == nil { + t.Fatal("Mini App origin mismatch was accepted") + } +} diff --git a/internal/rpc/account.go b/internal/rpc/account.go index 9f729fa9..b48cf1fb 100644 --- a/internal/rpc/account.go +++ b/internal/rpc/account.go @@ -380,14 +380,10 @@ func (r *Router) registerAccount(d *tlprofile.Dispatcher) { ID) }) registerRPC[*tg.AccountGetWebAuthorizationsRequest](d, tlprofile.SemanticMethodAccountGetWebAuthorizations, func(ctx context.Context, layerRequest *tg.AccountGetWebAuthorizationsRequest) (any, error) { - return tdesktop.WebAuthorizations(), nil + return r.onAccountGetWebAuthorizations(ctx) }) registerRPC[*tg.AccountResetWebAuthorizationRequest](d, tlprofile.SemanticMethodAccountResetWebAuthorization, func(ctx context.Context, layerRequest *tg.AccountResetWebAuthorizationRequest) (any, error) { - hash := layerRequest. - Hash - _ = hash - - return true, nil + return r.onAccountResetWebAuthorization(ctx, layerRequest.Hash) }) registerRPC[*tg.AccountResetWebAuthorizationsRequest](d, tlprofile.SemanticMethodAccountResetWebAuthorizations, func(ctx context.Context, layerRequest *tg.AccountResetWebAuthorizationsRequest) ( @@ -395,7 +391,7 @@ func (r *Router) registerAccount(d *tlprofile.Dispatcher) { // (无内置浏览器例外、不强制外部浏览器)。Android 启动时会拉取,缺它会反复 500 // NOT_IMPLEMENTED。空结构 Hash=0,客户端按默认(内置浏览器、无例外)渲染。 any, error) { - return true, nil + return r.onAccountResetWebAuthorizations(ctx) }) registerRPC[*tg.AccountGetWebBrowserSettingsRequest](d, tlprofile.SemanticMethodAccountGetWebBrowserSettings, func(ctx context.Context, layerRequest *tg.AccountGetWebBrowserSettingsRequest) (any, error) { hash := layerRequest. diff --git a/internal/rpc/botapi_gateway.go b/internal/rpc/botapi_gateway.go index 2df966ed..c6a02a56 100644 --- a/internal/rpc/botapi_gateway.go +++ b/internal/rpc/botapi_gateway.go @@ -834,6 +834,14 @@ func (r *Router) BotAPIEditMessageText(ctx context.Context, botID, chatID int64, return domain.Message{}, errors.New("MESSAGE_TOO_LONG") } peer := domain.Peer{Type: domain.PeerTypeUser, ID: chatID} + if setReplyMarkup { + if err := domain.ValidateReplyMarkup(replyMarkup); err != nil { + return domain.Message{}, replyMarkupErr(err) + } + if err := r.validateReplyMarkupForPeer(ctx, botID, peer, replyMarkup); err != nil { + return domain.Message{}, err + } + } res, err := r.deps.Messages.EditMessage(ctx, botID, domain.EditMessageRequest{ OwnerUserID: botID, Peer: peer, @@ -934,6 +942,11 @@ func (r *Router) BotAPIEditInlineMessageText(ctx context.Context, botID int64, i if err := domain.ValidateReplyMarkup(replyMarkup); err != nil { return false, replyMarkupErr(err) } + if setReplyMarkup { + if err := r.prepareTelegramLoginMarkup(ctx, botID, replyMarkup); err != nil { + return false, replyMarkupErr(err) + } + } req := &tg.MessagesEditInlineBotMessageRequest{ ID: tgInputBotInlineMessageID(inlineMessageID), NoWebpage: disableWebPagePreview, @@ -959,6 +972,11 @@ func (r *Router) BotAPIEditInlineRichMessage(ctx context.Context, botID int64, i if err := domain.ValidateReplyMarkup(replyMarkup); err != nil { return false, replyMarkupErr(err) } + if setReplyMarkup { + if err := r.prepareTelegramLoginMarkup(ctx, botID, replyMarkup); err != nil { + return false, replyMarkupErr(err) + } + } wire, err := tgInputRichMessageFromBotAPI(input) if err != nil { return false, err diff --git a/internal/rpc/bots_inline.go b/internal/rpc/bots_inline.go index e96bbca6..30c4520d 100644 --- a/internal/rpc/bots_inline.go +++ b/internal/rpc/bots_inline.go @@ -300,6 +300,9 @@ func (r *Router) domainInlineResultsFromTG(ctx context.Context, botID int64, req if err != nil { return domain.BotInlineResults{}, err } + if err := r.prepareTelegramLoginMarkup(ctx, botID, item.ReplyMarkup); err != nil { + return domain.BotInlineResults{}, replyMarkupErr(err) + } if _, ok := seen[item.ID]; ok { return domain.BotInlineResults{}, resultIDDuplicateErr() } diff --git a/internal/rpc/convert_markup.go b/internal/rpc/convert_markup.go index ef248d2e..9005e162 100644 --- a/internal/rpc/convert_markup.go +++ b/internal/rpc/convert_markup.go @@ -3,6 +3,7 @@ package rpc import ( "context" "errors" + "strings" "github.com/iamxvbaba/td/tg" "github.com/iamxvbaba/td/tgerr" @@ -14,6 +15,9 @@ import ( // a chat input field and are not supported in broadcast channels. Inline keyboards remain // valid in both megagroups and broadcasts. func (r *Router) validateReplyMarkupForPeer(ctx context.Context, userID int64, peer domain.Peer, markup *domain.MessageReplyMarkup) error { + if err := r.prepareTelegramLoginMarkup(ctx, userID, markup); err != nil { + return replyMarkupErr(err) + } if markup == nil || !markup.IsReplyKeyboardFamily() || peer.Type != domain.PeerTypeChannel { return nil } @@ -30,6 +34,92 @@ func (r *Router) validateReplyMarkupForPeer(ctx context.Context, userID int64, p return nil } +// prepareTelegramLoginMarkup resolves every login_url target and validates its +// linked web origin before persistence. It mutates only the freshly parsed +// request DTO and assigns a deterministic flattened button id, which is later +// re-read by messages.requestUrlAuth. +func (r *Router) prepareTelegramLoginMarkup(ctx context.Context, senderBotID int64, markup *domain.MessageReplyMarkup) error { + if markup == nil || markup.Kind() != domain.MessageReplyMarkupInline { + return nil + } + hasLoginButton := false + for rowIndex := range markup.Inline { + for buttonIndex := range markup.Inline[rowIndex] { + if markup.Inline[rowIndex][buttonIndex].Type == domain.MarkupButtonLoginURL { + hasLoginButton = true + break + } + } + if hasLoginButton { + break + } + } + if !hasLoginButton { + return nil + } + if r == nil || r.deps.TelegramLogin == nil || r.deps.Users == nil || senderBotID <= 0 { + return domain.ErrButtonTypeInvalid + } + sender, found, err := r.deps.Users.ByID(ctx, senderBotID, senderBotID) + if err != nil { + return err + } + if !found || !sender.Bot || sender.Deleted { + return domain.ErrButtonTypeInvalid + } + flatID := 0 + for rowIndex := range markup.Inline { + for buttonIndex := range markup.Inline[rowIndex] { + button := &markup.Inline[rowIndex][buttonIndex] + if button.Type != domain.MarkupButtonLoginURL { + flatID++ + continue + } + botID := button.LoginBotUserID + if button.LoginBotUsername != "" { + resolver, ok := r.deps.Users.(UserIdentityService) + if !ok { + return domain.ErrButtonInvalid + } + bot, found, err := resolver.ResolveUsername(ctx, senderBotID, strings.TrimPrefix(button.LoginBotUsername, "@")) + if err != nil { + return err + } + if !found || !bot.Bot || bot.Deleted { + return domain.ErrButtonInvalid + } + botID = bot.ID + } + if botID == 0 { + botID = senderBotID + } + bot, found, err := r.deps.Users.ByID(ctx, senderBotID, botID) + if err != nil { + return err + } + if !found || !bot.Bot || bot.Deleted { + return domain.ErrButtonInvalid + } + normalized, _, err := r.deps.TelegramLogin.ValidateMessageButton(ctx, botID, button.URL) + if err != nil { + if errors.Is(err, domain.ErrTelegramLoginURLInvalid) || errors.Is(err, domain.ErrTelegramLoginOriginNotAllowed) { + return domain.ErrButtonURLInvalid + } + if errors.Is(err, domain.ErrTelegramLoginClientDisabled) { + return domain.ErrButtonInvalid + } + return err + } + button.URL = normalized + button.LoginBotUserID = botID + button.LoginBotUsername = "" + button.ButtonID = flatID + flatID++ + } + } + return domain.ValidateReplyMarkup(markup) +} + // P3 reply_markup 错误码(对齐官方)。 func buttonDataInvalidErr() error { return tgerr.New(400, "BUTTON_DATA_INVALID") } func buttonInvalidErr() error { return tgerr.New(400, "BUTTON_INVALID") } @@ -175,21 +265,23 @@ func domainReplyKeyboardButton(button tg.KeyboardButtonClass) (domain.MarkupButt func domainInlineMarkup(inline *tg.ReplyInlineMarkup) (*domain.MessageReplyMarkup, error) { out := &domain.MessageReplyMarkup{Type: domain.MessageReplyMarkupInline, Inline: make([][]domain.MarkupButton, 0, len(inline.Rows))} + buttonID := 0 for _, row := range inline.Rows { domainRow := make([]domain.MarkupButton, 0, len(row.Buttons)) for _, btn := range row.Buttons { - db, err := domainMarkupButton(btn) + db, err := domainMarkupButton(btn, buttonID) if err != nil { return nil, err } domainRow = append(domainRow, db) + buttonID++ } out.Inline = append(out.Inline, domainRow) } return out, nil } -func domainMarkupButton(btn tg.KeyboardButtonClass) (domain.MarkupButton, error) { +func domainMarkupButton(btn tg.KeyboardButtonClass, buttonID int) (domain.MarkupButton, error) { style, icon, err := domainMarkupButtonStyle(btn) if err != nil { return domain.MarkupButton{}, err @@ -209,6 +301,26 @@ func domainMarkupButton(btn tg.KeyboardButtonClass) (domain.MarkupButton, error) Type: domain.MarkupButtonURL, Text: b.Text, URL: b.URL, Style: style, IconCustomEmojiID: icon, }, nil + case *tg.InputKeyboardButtonURLAuth: + botUserID := int64(0) + switch bot := b.Bot.(type) { + case nil, *tg.InputUserEmpty, *tg.InputUserSelf: + case *tg.InputUser: + botUserID = bot.UserID + default: + return domain.MarkupButton{}, domain.ErrButtonInvalid + } + return domain.MarkupButton{ + Type: domain.MarkupButtonLoginURL, Text: b.Text, URL: b.URL, + ForwardText: b.FwdText, ButtonID: buttonID, LoginBotUserID: botUserID, + RequestWriteAccess: b.RequestWriteAccess, Style: style, IconCustomEmojiID: icon, + }, nil + case *tg.KeyboardButtonURLAuth: + return domain.MarkupButton{ + Type: domain.MarkupButtonLoginURL, Text: b.Text, URL: b.URL, + ForwardText: b.FwdText, ButtonID: b.ButtonID, + Style: style, IconCustomEmojiID: icon, + }, nil case *tg.KeyboardButtonWebView: return domain.MarkupButton{Type: domain.MarkupButtonWebView, Text: b.Text, URL: b.URL, Style: style, IconCustomEmojiID: icon}, nil case *tg.KeyboardButtonSwitchInline: @@ -322,6 +434,15 @@ func tgMarkupButton(btn domain.MarkupButton) tg.KeyboardButtonClass { out.SetStyle(style) } return out + case domain.MarkupButtonLoginURL: + out := &tg.KeyboardButtonURLAuth{Text: btn.Text, URL: btn.URL, ButtonID: btn.ButtonID} + if btn.ForwardText != "" { + out.SetFwdText(btn.ForwardText) + } + if style, ok := tgMarkupButtonStyle(btn); ok { + out.SetStyle(style) + } + return out case domain.MarkupButtonWebView: out := &tg.KeyboardButtonWebView{Text: btn.Text, URL: btn.URL} if style, ok := tgMarkupButtonStyle(btn); ok { diff --git a/internal/rpc/convert_markup_test.go b/internal/rpc/convert_markup_test.go index a5336e79..834ca2df 100644 --- a/internal/rpc/convert_markup_test.go +++ b/internal/rpc/convert_markup_test.go @@ -71,6 +71,26 @@ func TestInlineButtonStyleTLDomainRoundTrip(t *testing.T) { } } +func TestLoginURLButtonTLDomainProjection(t *testing.T) { + button := &tg.InputKeyboardButtonURLAuth{ + Text: "Log in", URL: "https://example.com/login", Bot: &tg.InputUser{UserID: 9001, AccessHash: 77}, + } + button.SetRequestWriteAccess(true) + button.SetFwdText("Open login") + markup, err := domainReplyMarkupForSender(&tg.ReplyInlineMarkup{Rows: []tg.KeyboardButtonRow{{Buttons: []tg.KeyboardButtonClass{button}}}}, true) + if err != nil { + t.Fatal(err) + } + got := markup.Inline[0][0] + if got.Type != domain.MarkupButtonLoginURL || got.LoginBotUserID != 9001 || !got.RequestWriteAccess || got.ForwardText != "Open login" || got.ButtonID != 0 { + t.Fatalf("domain login_url = %#v", got) + } + wire, ok := tgReplyMarkup(markup).(*tg.ReplyInlineMarkup).Rows[0].Buttons[0].(*tg.KeyboardButtonURLAuth) + if !ok || wire.Text != "Log in" || wire.URL != "https://example.com/login" || wire.ButtonID != 0 || wire.FwdText != "Open login" { + t.Fatalf("wire login_url = %#v", wire) + } +} + func TestReplyKeyboardHideAndForceReplyTLDomainRoundTrip(t *testing.T) { hide, err := domainOutgoingReplyMarkupForSender(&tg.ReplyKeyboardHide{Selective: true}, true) if err != nil { diff --git a/internal/rpc/deps.go b/internal/rpc/deps.go index 2341b2bb..f4eab885 100644 --- a/internal/rpc/deps.go +++ b/internal/rpc/deps.go @@ -241,6 +241,24 @@ type UsersService interface { ByIDs(ctx context.Context, currentUserID int64, userIDs []int64) ([]domain.User, error) } +// TelegramLoginService is the domain-only boundary shared by the MTProto RPC +// edge and the public OIDC provider. PostgreSQL remains authoritative for all +// consent transitions; the RPC layer only projects domain state to TL. +type TelegramLoginService interface { + ValidateMessageButton(ctx context.Context, botUserID int64, rawURL string) (normalizedURL, domainName string, err error) + AuthorizeMessageButton(ctx context.Context, params domain.TelegramLoginMessageButtonAuthorization) (domain.TelegramLoginMessageButtonResult, error) + RequestByDeepLink(ctx context.Context, deepLink string) (domain.TelegramLoginRequest, error) + RequestByDeepLinkForOrigin(ctx context.Context, deepLink, inAppOrigin string) (domain.TelegramLoginRequest, error) + CheckMatchCode(ctx context.Context, deepLink, selected string) (bool, error) + Approve(ctx context.Context, deepLink string, identity domain.TelegramLoginIdentitySnapshot, writeAllowed, phoneShared bool, matchCode string) (domain.TelegramLoginRequest, domain.TelegramLoginWebAuthorization, error) + FinalizeRedirectByDeepLink(ctx context.Context, deepLink string) (string, error) + FinalizeInAppRedirectByDeepLink(ctx context.Context, deepLink string) (string, error) + Decline(ctx context.Context, deepLink string, userID int64) (domain.TelegramLoginRequest, error) + ListWebAuthorizations(ctx context.Context, userID int64) ([]domain.TelegramLoginWebAuthorization, error) + RevokeWebAuthorization(ctx context.Context, userID, hash int64) error + RevokeAllWebAuthorizations(ctx context.Context, userID int64) (int64, error) +} + // BatchViewerUsersResolver 是 UsersService 的可选能力:跨多个 viewer 一次性投影同一组 user // (fan-out 模板化,把 per-recipient 的 ByIDs(=ForViewer) 折叠成 O(owner) 查询)。结果按 viewer // 与 ByIDs(viewer, ids) 字节等价(personal photo overlay 除外,见 users.ByIDsForViewers)。 @@ -873,6 +891,7 @@ type Deps struct { EphemeralPush store.EphemeralPushBroker EphemeralReports store.EphemeralReportStore Users UsersService + TelegramLogin TelegramLoginService Updates UpdatesService BootstrapUpdates store.BootstrapUpdateJobStore BotAPIUpdates store.BotAPIUpdateStore diff --git a/internal/rpc/messages_bot_no_state.go b/internal/rpc/messages_bot_no_state.go index 2aea421e..8cedef6e 100644 --- a/internal/rpc/messages_bot_no_state.go +++ b/internal/rpc/messages_bot_no_state.go @@ -35,6 +35,9 @@ func (r *Router) onMessagesSavePreparedInlineMessage(ctx context.Context, req *t if err != nil { return nil, err } + if err := r.prepareTelegramLoginMarkup(ctx, botID, result.ReplyMarkup); err != nil { + return nil, replyMarkupErr(err) + } peerTypes, err := preparedInlinePeerTypesFromTG(req.PeerTypes) if err != nil { return nil, err @@ -159,6 +162,11 @@ func (r *Router) editPrivateInlineBotMessage(ctx context.Context, botID int64, t setReplyMarkup = true } } + if setReplyMarkup { + if err := r.prepareTelegramLoginMarkup(ctx, botID, replyMarkup); err != nil { + return false, replyMarkupErr(err) + } + } _, err = r.deps.Messages.EditMessage(ctx, target.OwnerUserID, domain.EditMessageRequest{ OwnerUserID: target.OwnerUserID, Peer: target.Peer, @@ -246,6 +254,11 @@ func (r *Router) editChannelInlineBotMessage(ctx context.Context, botID int64, t setReplyMarkup = true } } + if setReplyMarkup { + if err := r.prepareTelegramLoginMarkup(ctx, botID, replyMarkup); err != nil { + return false, replyMarkupErr(err) + } + } res, err := r.deps.Channels.EditInlineBotMessage(ctx, botID, domain.EditChannelMessageRequest{ UserID: target.SenderUserID, ChannelID: target.ChannelID, diff --git a/internal/rpc/messages_edit.go b/internal/rpc/messages_edit.go index 816daae4..2e09ca6a 100644 --- a/internal/rpc/messages_edit.go +++ b/internal/rpc/messages_edit.go @@ -110,6 +110,11 @@ func (r *Router) onMessagesEditMessage(ctx context.Context, req *tg.MessagesEdit setReplyMarkup = true } } + if setReplyMarkup { + if err := r.validateReplyMarkupForPeer(ctx, userID, peer, replyMarkup); err != nil { + return nil, err + } + } if peer.Type == domain.PeerTypeChannel { if r.deps.Channels == nil { return nil, peerIDInvalidErr() @@ -125,6 +130,8 @@ func (r *Router) onMessagesEditMessage(ctx context.Context, req *tg.MessagesEdit Message: message, Entities: domainMessageEntitiesForViewer(userID, entities), MentionUserIDs: mentionUserIDs, + SetReplyMarkup: setReplyMarkup, + ReplyMarkup: replyMarkup, SetRichMessage: replaceRichMessage, RichMessage: richMessage, EditDate: int(r.clock.Now().Unix()), diff --git a/internal/rpc/messages_register.go b/internal/rpc/messages_register.go index 515d19fc..809f1c5a 100644 --- a/internal/rpc/messages_register.go +++ b/internal/rpc/messages_register.go @@ -11,6 +11,18 @@ import ( // registerMessages 注册 messages.* RPC handler。 func (r *Router) registerMessages(d *tlprofile.Dispatcher) { + registerRPC[*tg.MessagesRequestURLAuthRequest](d, tlprofile.SemanticMethodMessagesRequestURLAuth, func(ctx context.Context, req *tg.MessagesRequestURLAuthRequest) (any, error) { + return r.onMessagesRequestURLAuth(ctx, req) + }) + registerRPC[*tg.MessagesAcceptURLAuthRequest](d, tlprofile.SemanticMethodMessagesAcceptURLAuth, func(ctx context.Context, req *tg.MessagesAcceptURLAuthRequest) (any, error) { + return r.onMessagesAcceptURLAuth(ctx, req) + }) + registerRPC[*tg.MessagesDeclineURLAuthRequest](d, tlprofile.SemanticMethodMessagesDeclineURLAuth, func(ctx context.Context, req *tg.MessagesDeclineURLAuthRequest) (any, error) { + return r.onMessagesDeclineURLAuth(ctx, req.URL) + }) + registerRPC[*tg.MessagesCheckURLAuthMatchCodeRequest](d, tlprofile.SemanticMethodMessagesCheckURLAuthMatchCode, func(ctx context.Context, req *tg.MessagesCheckURLAuthMatchCodeRequest) (any, error) { + return r.onMessagesCheckURLAuthMatchCode(ctx, req.URL, req.MatchCode) + }) registerRPC[*tg.MessagesReceivedMessagesRequest](d, tlprofile.SemanticMethodMessagesReceivedMessages, func(ctx context.Context, layerRequest *tg.MessagesReceivedMessagesRequest) (any, error) { return r.onMessagesReceivedMessages(ctx, layerRequest. MaxID) diff --git a/internal/rpc/messages_webview.go b/internal/rpc/messages_webview.go index 74c224c6..44e1b468 100644 --- a/internal/rpc/messages_webview.go +++ b/internal/rpc/messages_webview.go @@ -314,6 +314,9 @@ func (r *Router) onMessagesSendWebViewResultMessage(ctx context.Context, req *tg if err != nil { return nil, err } + if err := r.prepareTelegramLoginMarkup(ctx, botID, result.ReplyMarkup); err != nil { + return nil, replyMarkupErr(err) + } if err := r.sendWebViewDomainResultMessage(ctx, botID, req.BotQueryID, result); err != nil { return nil, err } @@ -336,6 +339,9 @@ func (r *Router) AnswerWebAppQueryFromBotAPI(ctx context.Context, botID int64, b } else if !found { return "", userBotRequiredErr() } + if err := r.prepareTelegramLoginMarkup(ctx, botID, result.ReplyMarkup); err != nil { + return "", replyMarkupErr(err) + } if err := r.sendWebViewDomainResultMessage(ctx, botID, botQueryID, result); err != nil { return "", err } @@ -362,6 +368,9 @@ func (r *Router) SavePreparedInlineMessageFromBotAPI(ctx context.Context, botID, } else if !found { return "", 0, userIDInvalidErr() } + if err := r.prepareTelegramLoginMarkup(ctx, botID, result.ReplyMarkup); err != nil { + return "", 0, replyMarkupErr(err) + } id, expireDate := r.inlines.savePreparedInlineContext(ctx, r.clock.Now(), botID, userID, result, peerTypes) return id, expireDate, nil } diff --git a/internal/rpc/telegram_login.go b/internal/rpc/telegram_login.go new file mode 100644 index 00000000..352a5b9b --- /dev/null +++ b/internal/rpc/telegram_login.go @@ -0,0 +1,434 @@ +package rpc + +import ( + "context" + "errors" + "math" + "net/url" + "strconv" + "strings" + "time" + + "github.com/iamxvbaba/td/tg" + "github.com/iamxvbaba/td/tgerr" + + "telesrv/internal/domain" +) + +func telegramLoginOAuthInvalidErr() error { return tgerr.New(500, "OAUTH_REQUEST_INVALID") } +func telegramLoginURLExpiredErr() error { return tgerr.New(400, "URL_EXPIRED") } +func telegramLoginURLInvalidErr() error { return tgerr.New(400, "URL_INVALID") } +func telegramLoginHashInvalidErr() error { return tgerr.New(400, "HASH_INVALID") } + +func telegramLoginRPCError(err error) error { + switch { + case errors.Is(err, domain.ErrTelegramLoginRequestExpired): + return telegramLoginURLExpiredErr() + case errors.Is(err, domain.ErrTelegramLoginURLInvalid): + return telegramLoginURLInvalidErr() + case errors.Is(err, domain.ErrTelegramLoginMatchCodeInvalid), + errors.Is(err, domain.ErrTelegramLoginRequestInvalid), + errors.Is(err, domain.ErrTelegramLoginRequestConflict), + errors.Is(err, domain.ErrTelegramLoginClientDisabled), + errors.Is(err, domain.ErrTelegramLoginOriginNotAllowed), + errors.Is(err, domain.ErrTelegramLoginRedirectNotAllowed), + errors.Is(err, domain.ErrTelegramLoginScopeInvalid), + errors.Is(err, domain.ErrTelegramLoginAuthorizationsTooMany): + return telegramLoginOAuthInvalidErr() + default: + return internalErr() + } +} + +func (r *Router) requireTelegramLoginUser(ctx context.Context) (int64, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil || userID <= 0 || r.deps.TelegramLogin == nil || r.deps.Users == nil { + return 0, internalErr() + } + self, err := r.deps.Users.Self(ctx, userID) + if err != nil || self.Bot || self.Deleted { + return 0, telegramLoginOAuthInvalidErr() + } + return userID, nil +} + +func (r *Router) telegramLoginRequestResult(ctx context.Context, viewerUserID int64, request domain.TelegramLoginRequest, deepLink string) (tg.URLAuthResultClass, error) { + switch request.Status { + case domain.TelegramLoginRequestApproved: + if request.AuthorizedUserID != viewerUserID { + return nil, telegramLoginOAuthInvalidErr() + } + return r.telegramLoginAcceptedResult(ctx, request, deepLink) + case domain.TelegramLoginRequestPending: + // Continue below. + case domain.TelegramLoginRequestDeclined, domain.TelegramLoginRequestExpired: + return nil, telegramLoginURLExpiredErr() + default: + return nil, telegramLoginOAuthInvalidErr() + } + bot, found, err := r.deps.Users.ByID(ctx, viewerUserID, request.BotUserID) + if err != nil { + return nil, internalErr() + } + if !found || !bot.Bot || bot.Deleted { + return nil, telegramLoginOAuthInvalidErr() + } + botTL := r.withBotProfileFlags(ctx, r.tgUser(bot)) + out := &tg.URLAuthResultRequest{ + RequestWriteAccess: request.Requests(domain.TelegramLoginScopeBotAccess), + RequestPhoneNumber: request.Requests(domain.TelegramLoginScopePhone), + MatchCodesFirst: request.MatchCodesFirst, + IsApp: request.IsApp, + Bot: botTL, + Domain: request.Domain, + } + // OAuth requests carry the complete device tuple. Keep the four fields on + // their shared flag together so old exact-layer codecs never see a partial + // conditional shape. + if request.Browser != "" && request.Platform != "" && request.IP != "" && request.Region != "" { + out.SetBrowser(request.Browser) + out.SetPlatform(request.Platform) + out.SetIP(request.IP) + out.SetRegion(request.Region) + } + if len(request.MatchCodes) > 0 { + out.SetMatchCodes(append([]string(nil), request.MatchCodes...)) + } + if request.UserIDHint > 0 { + out.SetUserIDHint(request.UserIDHint) + } + if request.IsApp && request.VerifiedAppName != "" { + out.SetVerifiedAppName(request.VerifiedAppName) + } + return out, nil +} + +func (r *Router) telegramLoginAcceptedResult(ctx context.Context, request domain.TelegramLoginRequest, deepLink string) (tg.URLAuthResultClass, error) { + accepted := &tg.URLAuthResultAccepted{} + switch { + case request.Source == domain.TelegramLoginRequestNative && request.IsApp: + redirectURL, err := r.deps.TelegramLogin.FinalizeRedirectByDeepLink(ctx, deepLink) + if err != nil { + return nil, telegramLoginRPCError(err) + } + accepted.SetURL(redirectURL) + case request.Source == domain.TelegramLoginRequestMiniApp: + resultURL, err := r.deps.TelegramLogin.FinalizeInAppRedirectByDeepLink(ctx, deepLink) + if err != nil { + return nil, telegramLoginRPCError(err) + } + accepted.SetURL(resultURL) + } + return accepted, nil +} + +func (r *Router) onMessagesRequestURLAuth(ctx context.Context, req *tg.MessagesRequestURLAuthRequest) (tg.URLAuthResultClass, error) { + userID, err := r.requireTelegramLoginUser(ctx) + if err != nil { + return nil, err + } + _, hasPeer := req.GetPeer() + urlValue, hasURL := req.GetURL() + _, hasOrigin := req.GetInAppOrigin() + if hasPeer == hasURL || (!hasPeer && strings.TrimSpace(urlValue) == "") || hasOrigin && !hasURL { + return nil, telegramLoginOAuthInvalidErr() + } + if hasPeer { + button, peer, err := r.telegramLoginButtonFromMessage(ctx, userID, req.Peer, req.MsgID, req.ButtonID) + if err != nil { + return nil, err + } + u, err := url.Parse(button.URL) + if err != nil || u.Hostname() == "" { + return nil, telegramLoginURLInvalidErr() + } + request := domain.TelegramLoginRequest{ + BotUserID: button.LoginBotUserID, Source: domain.TelegramLoginRequestMessageButton, + ResponseType: "legacy_url", RedirectURI: button.URL, Domain: u.Hostname(), + Scopes: []domain.TelegramLoginScope{domain.TelegramLoginScopeOpenID, domain.TelegramLoginScopeProfile}, + PeerType: peer.Type, PeerID: peer.ID, MessageID: req.MsgID, ButtonID: req.ButtonID, + Status: domain.TelegramLoginRequestPending, + } + if button.RequestWriteAccess { + request.Scopes = append(request.Scopes, domain.TelegramLoginScopeBotAccess) + } + return r.telegramLoginRequestResult(ctx, userID, request, "") + } + if hasOrigin && req.InAppOrigin == "" { + return nil, telegramLoginURLInvalidErr() + } + request, err := r.deps.TelegramLogin.RequestByDeepLinkForOrigin(ctx, urlValue, req.InAppOrigin) + if err != nil { + return nil, telegramLoginRPCError(err) + } + return r.telegramLoginRequestResult(ctx, userID, request, urlValue) +} + +func (r *Router) onMessagesAcceptURLAuth(ctx context.Context, req *tg.MessagesAcceptURLAuthRequest) (tg.URLAuthResultClass, error) { + userID, err := r.requireTelegramLoginUser(ctx) + if err != nil { + return nil, err + } + _, hasPeer := req.GetPeer() + deepLink, hasURL := req.GetURL() + matchCode, hasMatchCode := req.GetMatchCode() + if hasPeer == hasURL || (!hasPeer && strings.TrimSpace(deepLink) == "") || (hasMatchCode && matchCode == "") { + return nil, telegramLoginOAuthInvalidErr() + } + if hasPeer { + if hasMatchCode || req.SharePhoneNumber { + return nil, telegramLoginOAuthInvalidErr() + } + button, peer, err := r.telegramLoginButtonFromMessage(ctx, userID, req.Peer, req.MsgID, req.ButtonID) + if err != nil { + return nil, err + } + if r.deps.Bots == nil { + return nil, internalErr() + } + profile, found, err := r.deps.Bots.BotInfo(ctx, button.LoginBotUserID) + if err != nil { + return nil, internalErr() + } + if !found || profile.TokenSecret == "" { + return nil, telegramLoginOAuthInvalidErr() + } + self, err := r.deps.Users.Self(ctx, userID) + if err != nil { + return nil, internalErr() + } + identity := r.telegramLoginIdentity(self) + result, err := r.deps.TelegramLogin.AuthorizeMessageButton(ctx, domain.TelegramLoginMessageButtonAuthorization{ + UserID: userID, BotUserID: button.LoginBotUserID, + BotToken: domain.FormatBotToken(button.LoginBotUserID, profile.TokenSecret), URL: button.URL, + RequestWriteAccess: button.RequestWriteAccess, WriteAllowed: req.WriteAllowed, + Peer: peer, MessageID: req.MsgID, ButtonID: req.ButtonID, + Browser: "Telegram", Platform: "Telegram Client", IP: "Unknown IP", Region: "Unknown region", + Identity: identity, + }) + if err != nil { + return nil, telegramLoginRPCError(err) + } + accepted := &tg.URLAuthResultAccepted{} + accepted.SetURL(result.URL) + return accepted, nil + } + request, err := r.deps.TelegramLogin.RequestByDeepLink(ctx, deepLink) + if err != nil { + return nil, telegramLoginRPCError(err) + } + if request.Status == domain.TelegramLoginRequestApproved { + if request.AuthorizedUserID == userID { + return r.telegramLoginAcceptedResult(ctx, request, deepLink) + } + return nil, telegramLoginOAuthInvalidErr() + } + if request.Status != domain.TelegramLoginRequestPending { + return nil, telegramLoginURLExpiredErr() + } + self, err := r.deps.Users.Self(ctx, userID) + if err != nil { + return nil, internalErr() + } + identity := r.telegramLoginIdentity(self) + approved, _, err := r.deps.TelegramLogin.Approve(ctx, deepLink, identity, req.WriteAllowed, req.SharePhoneNumber, matchCode) + if err != nil { + return nil, telegramLoginRPCError(err) + } + if approved.AuthorizedUserID != userID { + return nil, telegramLoginOAuthInvalidErr() + } + return r.telegramLoginAcceptedResult(ctx, approved, deepLink) +} + +func (r *Router) telegramLoginIdentity(self domain.User) domain.TelegramLoginIdentitySnapshot { + identity := domain.TelegramLoginIdentitySnapshot{ + UserID: self.ID, Name: strings.TrimSpace(strings.TrimSpace(self.FirstName) + " " + strings.TrimSpace(self.LastName)), + GivenName: self.FirstName, FamilyName: self.LastName, + PreferredUsername: self.Username, PhoneNumber: self.Phone, + } + if strings.TrimSpace(r.cfg.PublicBaseURL) != "" && self.Username != "" && self.PhotoID > 0 { + identity.Picture = strings.TrimSuffix(r.cfg.PublicBaseURL, "/") + "/_public/avatar/" + url.PathEscape(self.Username) + "/" + strconv.FormatInt(self.PhotoID, 10) + } + return identity +} + +func (r *Router) telegramLoginButtonFromMessage(ctx context.Context, userID int64, inputPeer tg.InputPeerClass, messageID, buttonID int) (domain.MarkupButton, domain.Peer, error) { + if messageID <= 0 || messageID > domain.MaxMessageBoxID || buttonID < 0 { + return domain.MarkupButton{}, domain.Peer{}, telegramLoginOAuthInvalidErr() + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, inputPeer) + if err != nil { + return domain.MarkupButton{}, domain.Peer{}, err + } + var markup *domain.MessageReplyMarkup + switch peer.Type { + case domain.PeerTypeUser: + message, found, err := r.lookupOwnerMessage(ctx, userID, messageID) + if err != nil { + return domain.MarkupButton{}, domain.Peer{}, internalErr() + } + if !found || message.Peer != peer { + return domain.MarkupButton{}, domain.Peer{}, telegramLoginOAuthInvalidErr() + } + markup = message.ReplyMarkup + case domain.PeerTypeChannel: + if r.deps.Channels == nil { + return domain.MarkupButton{}, domain.Peer{}, telegramLoginOAuthInvalidErr() + } + history, err := r.deps.Channels.GetMessages(ctx, userID, peer.ID, []int{messageID}) + if err != nil || len(history.Messages) != 1 || history.Messages[0].ID != messageID { + return domain.MarkupButton{}, domain.Peer{}, telegramLoginOAuthInvalidErr() + } + markup = history.Messages[0].ReplyMarkup + default: + return domain.MarkupButton{}, domain.Peer{}, telegramLoginOAuthInvalidErr() + } + if markup == nil || markup.Kind() != domain.MessageReplyMarkupInline { + return domain.MarkupButton{}, domain.Peer{}, telegramLoginOAuthInvalidErr() + } + for _, row := range markup.Inline { + for _, button := range row { + if button.Type == domain.MarkupButtonLoginURL && button.ButtonID == buttonID && button.LoginBotUserID > 0 { + return button, peer, nil + } + } + } + return domain.MarkupButton{}, domain.Peer{}, telegramLoginOAuthInvalidErr() +} + +func (r *Router) onMessagesDeclineURLAuth(ctx context.Context, deepLink string) (bool, error) { + userID, err := r.requireTelegramLoginUser(ctx) + if err != nil { + return false, err + } + if strings.TrimSpace(deepLink) == "" { + return false, telegramLoginURLInvalidErr() + } + request, err := r.deps.TelegramLogin.RequestByDeepLink(ctx, deepLink) + if err != nil { + return false, telegramLoginRPCError(err) + } + if request.Status == domain.TelegramLoginRequestDeclined { + return true, nil + } + if request.Status != domain.TelegramLoginRequestPending { + return false, telegramLoginOAuthInvalidErr() + } + if _, err := r.deps.TelegramLogin.Decline(ctx, deepLink, userID); err != nil { + return false, telegramLoginRPCError(err) + } + return true, nil +} + +func (r *Router) onMessagesCheckURLAuthMatchCode(ctx context.Context, deepLink, matchCode string) (bool, error) { + if _, err := r.requireTelegramLoginUser(ctx); err != nil { + return false, err + } + if strings.TrimSpace(deepLink) == "" || matchCode == "" { + return false, telegramLoginURLInvalidErr() + } + ok, err := r.deps.TelegramLogin.CheckMatchCode(ctx, deepLink, matchCode) + if err != nil { + return false, telegramLoginRPCError(err) + } + return ok, nil +} + +func (r *Router) onAccountGetWebAuthorizations(ctx context.Context) (*tg.AccountWebAuthorizations, error) { + if r.deps.TelegramLogin == nil { + if _, _, err := r.currentUserID(ctx); err != nil { + return nil, internalErr() + } + return &tg.AccountWebAuthorizations{Authorizations: []tg.WebAuthorization{}, Users: []tg.UserClass{}}, nil + } + userID, err := r.requireTelegramLoginUser(ctx) + if err != nil { + return nil, err + } + authorizations, err := r.deps.TelegramLogin.ListWebAuthorizations(ctx, userID) + if err != nil { + return nil, internalErr() + } + result := &tg.AccountWebAuthorizations{ + Authorizations: make([]tg.WebAuthorization, 0, len(authorizations)), + Users: []tg.UserClass{}, + } + botIDs := make([]int64, 0, len(authorizations)) + seenBots := make(map[int64]struct{}, len(authorizations)) + for _, authorization := range authorizations { + result.Authorizations = append(result.Authorizations, tg.WebAuthorization{ + Hash: authorization.Hash, BotID: authorization.BotUserID, Domain: authorization.Domain, + Browser: authorization.Browser, Platform: authorization.Platform, + DateCreated: telegramLoginUnixInt(authorization.CreatedAt), DateActive: telegramLoginUnixInt(authorization.LastActiveAt), + IP: authorization.IP, Region: authorization.Region, + }) + if _, duplicate := seenBots[authorization.BotUserID]; !duplicate { + seenBots[authorization.BotUserID] = struct{}{} + botIDs = append(botIDs, authorization.BotUserID) + } + } + if len(botIDs) > 0 { + bots, err := r.deps.Users.ByIDs(ctx, userID, botIDs) + if err != nil { + return nil, internalErr() + } + for _, bot := range bots { + if bot.Bot && !bot.Deleted { + result.Users = append(result.Users, r.withBotProfileFlags(ctx, r.tgUser(bot))) + } + } + } + return result, nil +} + +func (r *Router) onAccountResetWebAuthorization(ctx context.Context, hash int64) (bool, error) { + if r.deps.TelegramLogin == nil { + if _, _, err := r.currentUserID(ctx); err != nil { + return false, internalErr() + } + return true, nil + } + userID, err := r.requireTelegramLoginUser(ctx) + if err != nil { + return false, err + } + if hash == 0 { + return false, telegramLoginHashInvalidErr() + } + if err := r.deps.TelegramLogin.RevokeWebAuthorization(ctx, userID, hash); err != nil { + if errors.Is(err, domain.ErrTelegramLoginWebAuthHashInvalid) { + return false, telegramLoginHashInvalidErr() + } + return false, internalErr() + } + return true, nil +} + +func (r *Router) onAccountResetWebAuthorizations(ctx context.Context) (bool, error) { + if r.deps.TelegramLogin == nil { + if _, _, err := r.currentUserID(ctx); err != nil { + return false, internalErr() + } + return true, nil + } + userID, err := r.requireTelegramLoginUser(ctx) + if err != nil { + return false, err + } + if _, err := r.deps.TelegramLogin.RevokeAllWebAuthorizations(ctx, userID); err != nil { + return false, internalErr() + } + return true, nil +} + +func telegramLoginUnixInt(value time.Time) int { + unix := value.Unix() + if unix < 0 { + return 0 + } + if unix > math.MaxInt32 { + return math.MaxInt32 + } + return int(unix) +} diff --git a/internal/rpc/telegram_login_rpc_test.go b/internal/rpc/telegram_login_rpc_test.go new file mode 100644 index 00000000..6b22ce70 --- /dev/null +++ b/internal/rpc/telegram_login_rpc_test.go @@ -0,0 +1,380 @@ +package rpc + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "net/url" + "sort" + "strconv" + "strings" + "testing" + "time" + + "github.com/iamxvbaba/td/clock" + "github.com/iamxvbaba/td/tg" + "github.com/iamxvbaba/td/tlprofile" + "go.uber.org/zap/zaptest" + + telegramloginapp "telesrv/internal/app/telegramlogin" + appusers "telesrv/internal/app/users" + "telesrv/internal/domain" + "telesrv/internal/store/memory" +) + +type telegramLoginBotPermissionAdapter struct{ bots BotsService } + +func (a telegramLoginBotPermissionAdapter) AllowBotSendMessage(ctx context.Context, botUserID, userID int64, fromRequest bool) (bool, error) { + return a.bots.AllowSendMessage(ctx, userID, botUserID, fromRequest) +} + +type telegramLoginRPCFixture struct { + ctx context.Context + service *telegramloginapp.Service + router *Router + user domain.User + intruder domain.User + bot domain.User + client telegramloginapp.ClientCredentials + redirect string +} + +func newTelegramLoginRPCFixture(t *testing.T) *telegramLoginRPCFixture { + t.Helper() + ctx := context.Background() + users := memory.NewUserStore() + user, err := users.Create(ctx, domain.User{Phone: "+15551001", FirstName: "Alice", LastName: "Example", Username: "alice", AccessHash: 11}) + if err != nil { + t.Fatal(err) + } + intruder, err := users.Create(ctx, domain.User{Phone: "+15551002", FirstName: "Mallory", Username: "mallory", AccessHash: 13}) + if err != nil { + t.Fatal(err) + } + bot, err := users.Create(ctx, domain.User{FirstName: "Login Bot", Username: "login_rpc_bot", AccessHash: 12, Bot: true, BotInfoVersion: 1}) + if err != nil { + t.Fatal(err) + } + sealKey := make([]byte, 32) + sealKey[0] = 3 + sealer, err := telegramloginapp.NewCodeSealer("test", map[string][]byte{"test": sealKey}) + if err != nil { + t.Fatal(err) + } + pepper := make([]byte, 32) + pepper[0] = 4 + service, err := telegramloginapp.NewService(memory.NewTelegramLoginStore(nil), sealer, telegramloginapp.Config{ + Issuer: "https://oauth.test", AppScheme: "telesrv", ClientSecretPepper: pepper, + Now: func() time.Time { return time.Unix(1_780_000_000, 0).UTC() }, + }) + if err != nil { + t.Fatal(err) + } + client, err := service.CreateClient(ctx, bot.ID, domain.TelegramLoginSigningRS256) + if err != nil { + t.Fatal(err) + } + redirect := "https://rp.test/callback" + if _, err := service.AddAllowedURL(ctx, bot.ID, domain.TelegramLoginAllowedRedirectURI, redirect); err != nil { + t.Fatal(err) + } + if _, err := service.AddAllowedURL(ctx, bot.ID, domain.TelegramLoginAllowedWebOrigin, "https://rp.test"); err != nil { + t.Fatal(err) + } + router := New(Config{}, Deps{Users: appusers.NewService(users), TelegramLogin: service}, zaptest.NewLogger(t), clock.System) + return &telegramLoginRPCFixture{ctx: WithUserID(ctx, user.ID), service: service, router: router, user: user, intruder: intruder, bot: bot, client: client, redirect: redirect} +} + +func (f *telegramLoginRPCFixture) authorization(t *testing.T, match bool) telegramloginapp.CreatedAuthorization { + t.Helper() + challenge, err := telegramloginapp.PKCEChallenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk") + if err != nil { + t.Fatal(err) + } + created, err := f.service.CreateAuthorization(f.ctx, telegramloginapp.CreateAuthorizationParams{ + ClientID: f.client.Client.ClientID, RedirectURI: f.redirect, ResponseType: "code", + Scope: "openid profile telegram:bot_access", CodeChallenge: challenge, CodeChallengeMethod: "S256", + IncludeMatchCodes: match, MatchCodesFirst: match, + }) + if err != nil { + t.Fatal(err) + } + return created +} + +func TestTelegramLoginRPCsAcrossExactLayerProfiles(t *testing.T) { + for profile := tlprofile.Profile225; profile <= tlprofile.Profile228; profile++ { + t.Run(fmt.Sprintf("layer_%d", profile), func(t *testing.T) { + f := newTelegramLoginRPCFixture(t) + + approve := f.authorization(t, true) + // TDesktop normalizes the configured telesrv:// launcher to the + // official internal tg://oauth form before invoking MTProto. + canonicalURL := strings.Replace(approve.DeepLink, "telesrv://", "tg://", 1) + request := &tg.MessagesRequestURLAuthRequest{} + request.SetURL(canonicalURL) + result, method := dispatchExactLayerRPCTest(t, f.router, f.ctx, profile, request) + if method != "messages.requestUrlAuth" { + t.Fatalf("method = %q", method) + } + prompt, ok := dispatchCanonicalValue(result).(*tg.URLAuthResultRequest) + if !ok || prompt.Bot.GetID() != f.bot.ID || !prompt.RequestWriteAccess || len(prompt.MatchCodes) != 5 { + t.Fatalf("request result = %#v", dispatchCanonicalValue(result)) + } + + checked, method := dispatchExactLayerRPCTest(t, f.router, f.ctx, profile, &tg.MessagesCheckURLAuthMatchCodeRequest{ + URL: canonicalURL, MatchCode: approve.Request.MatchCode, + }) + if method != "messages.checkUrlAuthMatchCode" || dispatchCanonicalValue(checked) != true { + t.Fatalf("check result = %#v method=%q", dispatchCanonicalValue(checked), method) + } + accept := &tg.MessagesAcceptURLAuthRequest{WriteAllowed: true} + accept.SetURL(canonicalURL) + accept.SetMatchCode(approve.Request.MatchCode) + accepted, method := dispatchExactLayerRPCTest(t, f.router, f.ctx, profile, accept) + if method != "messages.acceptUrlAuth" { + t.Fatalf("accept method = %q", method) + } + if _, ok := dispatchCanonicalValue(accepted).(*tg.URLAuthResultAccepted); !ok { + t.Fatalf("accept result = %#v", dispatchCanonicalValue(accepted)) + } + + decline := f.authorization(t, false) + declined, method := dispatchExactLayerRPCTest(t, f.router, f.ctx, profile, &tg.MessagesDeclineURLAuthRequest{URL: decline.DeepLink}) + if method != "messages.declineUrlAuth" || dispatchCanonicalValue(declined) != true { + t.Fatalf("decline result = %#v method=%q", dispatchCanonicalValue(declined), method) + } + + listed, method := dispatchExactLayerRPCTest(t, f.router, f.ctx, profile, &tg.AccountGetWebAuthorizationsRequest{}) + web, ok := dispatchCanonicalValue(listed).(*tg.AccountWebAuthorizations) + if method != "account.getWebAuthorizations" || !ok || len(web.Authorizations) != 1 || web.Authorizations[0].BotID != f.bot.ID { + t.Fatalf("getWebAuthorizations = %#v method=%q", dispatchCanonicalValue(listed), method) + } + reset, method := dispatchExactLayerRPCTest(t, f.router, f.ctx, profile, &tg.AccountResetWebAuthorizationRequest{Hash: web.Authorizations[0].Hash}) + if method != "account.resetWebAuthorization" || dispatchCanonicalValue(reset) != true { + t.Fatalf("resetWebAuthorization = %#v method=%q", dispatchCanonicalValue(reset), method) + } + + const nativeCallback = "bedolaga://telegram-login" + if _, err := f.service.AddNativeApp(f.ctx, f.bot.ID, domain.TelegramLoginNativeAndroid, + "dev.bedolaga.demo", strings.Repeat("A", 64), nativeCallback, "Bedolaga Android Demo"); err != nil { + t.Fatal(err) + } + challenge, err := telegramloginapp.PKCEChallenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk") + if err != nil { + t.Fatal(err) + } + native, err := f.service.CreateAuthorization(f.ctx, telegramloginapp.CreateAuthorizationParams{ + ClientID: f.client.Client.ClientID, RedirectURI: nativeCallback, ResponseType: "code", + Scope: "profile", CodeChallenge: challenge, CodeChallengeMethod: "S256", + NativePlatform: domain.TelegramLoginNativeAndroid, IncludeMatchCodes: true, MatchCodesFirst: true, + }) + if err != nil { + t.Fatal(err) + } + nativeRequest := &tg.MessagesRequestURLAuthRequest{} + nativeRequest.SetURL(native.DeepLink) + nativeResult, _ := dispatchExactLayerRPCTest(t, f.router, f.ctx, profile, nativeRequest) + nativePrompt, ok := dispatchCanonicalValue(nativeResult).(*tg.URLAuthResultRequest) + if !ok || !nativePrompt.IsApp || nativePrompt.VerifiedAppName != "Bedolaga Android Demo" || len(nativePrompt.MatchCodes) != 5 { + t.Fatalf("native request result = %#v", dispatchCanonicalValue(nativeResult)) + } + nativeAccept := &tg.MessagesAcceptURLAuthRequest{} + nativeAccept.SetURL(native.DeepLink) + nativeAccept.SetMatchCode(native.Request.MatchCode) + nativeAcceptedResult, _ := dispatchExactLayerRPCTest(t, f.router, f.ctx, profile, nativeAccept) + nativeAccepted, ok := dispatchCanonicalValue(nativeAcceptedResult).(*tg.URLAuthResultAccepted) + if !ok || !strings.HasPrefix(nativeAccepted.URL, nativeCallback+"?code=") { + t.Fatalf("native accepted result = %#v", dispatchCanonicalValue(nativeAcceptedResult)) + } + nativeRetryResult, _ := dispatchExactLayerRPCTest(t, f.router, f.ctx, profile, nativeRequest) + nativeRetry, ok := dispatchCanonicalValue(nativeRetryResult).(*tg.URLAuthResultAccepted) + if !ok || nativeRetry.URL != nativeAccepted.URL { + t.Fatalf("native retry result = %#v, want URL %q", dispatchCanonicalValue(nativeRetryResult), nativeAccepted.URL) + } + + const miniAppOrigin = "https://rp.test" + miniApp, err := f.service.CreateAuthorization(f.ctx, telegramloginapp.CreateAuthorizationParams{ + ClientID: f.client.Client.ClientID, RedirectURI: miniAppOrigin + "/", ResponseType: "post_message", + Scope: "openid profile", Origin: miniAppOrigin, InAppOrigin: miniAppOrigin, + Source: domain.TelegramLoginRequestMiniApp, IncludeMatchCodes: true, MatchCodesFirst: true, + }) + if err != nil { + t.Fatal(err) + } + miniAppRequest := &tg.MessagesRequestURLAuthRequest{} + miniAppRequest.SetURL(miniApp.DeepLink) + miniAppRequest.SetInAppOrigin(miniAppOrigin) + miniAppResult, method := dispatchExactLayerRPCTest(t, f.router, f.ctx, profile, miniAppRequest) + miniAppPrompt, ok := dispatchCanonicalValue(miniAppResult).(*tg.URLAuthResultRequest) + if method != "messages.requestUrlAuth" || !ok || len(miniAppPrompt.MatchCodes) != 5 { + t.Fatalf("mini-app request result = %#v method=%q", dispatchCanonicalValue(miniAppResult), method) + } + miniAppAccept := &tg.MessagesAcceptURLAuthRequest{} + miniAppAccept.SetURL(miniApp.DeepLink) + miniAppAccept.SetMatchCode(miniApp.Request.MatchCode) + miniAppAcceptedResult, method := dispatchExactLayerRPCTest(t, f.router, f.ctx, profile, miniAppAccept) + miniAppAccepted, ok := dispatchCanonicalValue(miniAppAcceptedResult).(*tg.URLAuthResultAccepted) + if method != "messages.acceptUrlAuth" || !ok || !strings.HasPrefix(miniAppAccepted.URL, "https://oauth.test/inapp?token=") { + t.Fatalf("mini-app accepted result = %#v method=%q", dispatchCanonicalValue(miniAppAcceptedResult), method) + } + miniAppRetryResult, _ := dispatchExactLayerRPCTest(t, f.router, f.ctx, profile, miniAppRequest) + miniAppRetry, ok := dispatchCanonicalValue(miniAppRetryResult).(*tg.URLAuthResultAccepted) + if !ok || miniAppRetry.URL != miniAppAccepted.URL { + t.Fatalf("mini-app retry result = %#v, want URL %q", dispatchCanonicalValue(miniAppRetryResult), miniAppAccepted.URL) + } + resetAll, method := dispatchExactLayerRPCTest(t, f.router, f.ctx, profile, &tg.AccountResetWebAuthorizationsRequest{}) + if method != "account.resetWebAuthorizations" || dispatchCanonicalValue(resetAll) != true { + t.Fatalf("resetWebAuthorizations = %#v method=%q", dispatchCanonicalValue(resetAll), method) + } + }) + } +} + +func TestTelegramLoginApprovedDeepLinkRejectsAnotherUser(t *testing.T) { + f := newTelegramLoginRPCFixture(t) + created := f.authorization(t, false) + accept := &tg.MessagesAcceptURLAuthRequest{} + accept.SetURL(created.DeepLink) + if _, err := f.router.onMessagesAcceptURLAuth(f.ctx, accept); err != nil { + t.Fatal(err) + } + request := &tg.MessagesRequestURLAuthRequest{} + request.SetURL(created.DeepLink) + if _, err := f.router.onMessagesRequestURLAuth(WithUserID(context.Background(), f.intruder.ID), request); err == nil { + t.Fatal("another user observed an approved deep link as accepted") + } +} + +func TestTelegramLoginMessageButtonRereadSignsAndGrantsWriteAccess(t *testing.T) { + f := newBotAPIReceiveFixture(t, false) + sealKey := make([]byte, 32) + sealKey[0] = 7 + sealer, err := telegramloginapp.NewCodeSealer("test", map[string][]byte{"test": sealKey}) + if err != nil { + t.Fatal(err) + } + pepper := make([]byte, 32) + pepper[0] = 8 + loginStore := memory.NewTelegramLoginStore(telegramLoginBotPermissionAdapter{bots: f.router.deps.Bots}) + login, err := telegramloginapp.NewService(loginStore, sealer, telegramloginapp.Config{ + Issuer: "https://oauth.test", AppScheme: "telesrv", ClientSecretPepper: pepper, + Now: func() time.Time { return time.Unix(1_780_000_000, 0).UTC() }, + }) + if err != nil { + t.Fatal(err) + } + if _, err := login.CreateClient(f.ctx, f.bot.ID, domain.TelegramLoginSigningRS256); err != nil { + t.Fatal(err) + } + if _, err := login.AddAllowedURL(f.ctx, f.bot.ID, domain.TelegramLoginAllowedWebOrigin, "https://rp.test"); err != nil { + t.Fatal(err) + } + f.router.deps.TelegramLogin = login + + markup := &domain.MessageReplyMarkup{Type: domain.MessageReplyMarkupInline, Inline: [][]domain.MarkupButton{{{ + Type: domain.MarkupButtonLoginURL, Text: "Log in", URL: "https://rp.test/login?next=%2Fhome", RequestWriteAccess: true, + }}}} + if _, err := f.router.BotAPISendMessage(f.ctx, f.bot.ID, f.owner.ID, "Authorize", nil, markup, false, false, 0); err != nil { + t.Fatalf("BotAPISendMessage: %v", err) + } + history, err := f.messages.GetHistory(f.ctx, f.owner.ID, domain.MessageFilter{ + HasPeer: true, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: f.bot.ID}, Limit: 10, + }) + if err != nil || len(history.Messages) == 0 { + t.Fatalf("GetHistory: messages=%d err=%v", len(history.Messages), err) + } + message := history.Messages[0] + if message.ReplyMarkup == nil || message.ReplyMarkup.Inline[0][0].LoginBotUserID != f.bot.ID { + t.Fatalf("persisted login button = %#v", message.ReplyMarkup) + } + + peer := &tg.InputPeerUser{UserID: f.bot.ID, AccessHash: f.bot.AccessHash} + request := &tg.MessagesRequestURLAuthRequest{} + request.SetPeer(peer) + request.SetMsgID(message.ID) + request.SetButtonID(0) + requested, err := f.router.onMessagesRequestURLAuth(WithUserID(f.ctx, f.owner.ID), request) + if err != nil { + t.Fatalf("requestUrlAuth: %v", err) + } + prompt, ok := requested.(*tg.URLAuthResultRequest) + if !ok || !prompt.RequestWriteAccess || prompt.Domain != "rp.test" { + t.Fatalf("requestUrlAuth result = %#v", requested) + } + accept := &tg.MessagesAcceptURLAuthRequest{} + accept.SetWriteAllowed(true) + accept.SetPeer(peer) + accept.SetMsgID(message.ID) + accept.SetButtonID(0) + accepted, err := f.router.onMessagesAcceptURLAuth(WithUserID(f.ctx, f.owner.ID), accept) + if err != nil { + t.Fatalf("acceptUrlAuth: %v", err) + } + final, ok := accepted.(*tg.URLAuthResultAccepted) + if !ok || final.URL == "" { + t.Fatalf("acceptUrlAuth result = %#v", accepted) + } + verifyLegacyTelegramLoginURL(t, final.URL, domain.FormatBotToken(f.bot.ID, "secret"), f.owner.ID) + if allowed, err := f.router.deps.Bots.CanSendMessage(f.ctx, f.owner.ID, f.bot.ID); err != nil || !allowed { + t.Fatalf("bot write permission = %v,%v", allowed, err) + } + web, err := login.ListWebAuthorizations(f.ctx, f.owner.ID) + if err != nil || len(web) != 1 || !web[0].BotAccessGranted || web[0].Domain != "rp.test" { + t.Fatalf("web authorizations = %#v err=%v", web, err) + } + + // The server must re-read durable message state. A forged button id never + // falls back to URL data supplied by the client. + forged := &tg.MessagesAcceptURLAuthRequest{} + forged.SetPeer(peer) + forged.SetMsgID(message.ID) + forged.SetButtonID(99) + if _, err := f.router.onMessagesAcceptURLAuth(WithUserID(f.ctx, f.owner.ID), forged); err == nil { + t.Fatal("forged button id was accepted") + } +} + +func TestTelegramLoginMarkupRequiresBotSender(t *testing.T) { + f := newTelegramLoginRPCFixture(t) + markup := &domain.MessageReplyMarkup{Type: domain.MessageReplyMarkupInline, Inline: [][]domain.MarkupButton{{{ + Type: domain.MarkupButtonLoginURL, Text: "Log in", URL: "https://rp.test/login", + }}}} + if err := f.router.prepareTelegramLoginMarkup(WithUserID(f.ctx, f.user.ID), f.user.ID, markup); !errors.Is(err, domain.ErrButtonTypeInvalid) { + t.Fatalf("ordinary user login_url error = %v, want ErrButtonTypeInvalid", err) + } +} + +func verifyLegacyTelegramLoginURL(t *testing.T, raw, botToken string, wantUserID int64) { + t.Helper() + u, err := url.Parse(raw) + if err != nil { + t.Fatal(err) + } + query := u.Query() + provided := query.Get("hash") + query.Del("hash") + if query.Get("id") != strconv.FormatInt(wantUserID, 10) || query.Get("auth_date") == "" || query.Get("next") != "/home" { + t.Fatalf("legacy login query = %#v", query) + } + keys := make([]string, 0, len(query)) + for key := range query { + if key != "next" { // Existing application query fields are not signed. + keys = append(keys, key) + } + } + sort.Strings(keys) + lines := make([]string, 0, len(keys)) + for _, key := range keys { + lines = append(lines, key+"="+query.Get(key)) + } + secret := sha256.Sum256([]byte(botToken)) + mac := hmac.New(sha256.New, secret[:]) + _, _ = mac.Write([]byte(strings.Join(lines, "\n"))) + if !hmac.Equal([]byte(strings.ToLower(provided)), []byte(hex.EncodeToString(mac.Sum(nil)))) { + t.Fatalf("legacy login hash = %q, want %s", provided, hex.EncodeToString(mac.Sum(nil))) + } +} diff --git a/internal/store/memory/bot.go b/internal/store/memory/bot.go index c2eac3b8..63f457d1 100644 --- a/internal/store/memory/bot.go +++ b/internal/store/memory/bot.go @@ -75,6 +75,9 @@ func botFatherSeedProfile() domain.BotProfile { {Command: "mybots", Description: "list your bots"}, {Command: "token", Description: "show a bot's token"}, {Command: "revoke", Description: "revoke a bot's token"}, + {Command: "setlogin", Description: "configure Telegram Login"}, + {Command: "logininfo", Description: "show Telegram Login configuration"}, + {Command: "resetloginsecret", Description: "rotate an OIDC Client Secret"}, {Command: "cancel", Description: "cancel the current operation"}, {Command: "help", Description: "show help"}, }, diff --git a/internal/store/memory/telegram_login.go b/internal/store/memory/telegram_login.go new file mode 100644 index 00000000..3cbc2d8b --- /dev/null +++ b/internal/store/memory/telegram_login.go @@ -0,0 +1,721 @@ +package memory + +import ( + "context" + "sort" + "strconv" + "sync" + "time" + + "telesrv/internal/domain" +) + +type telegramLoginBotPermissionWriter interface { + AllowBotSendMessage(ctx context.Context, botUserID, userID int64, fromRequest bool) (bool, error) +} + +// TelegramLoginStore is the deterministic in-memory implementation used by +// application and RPC tests. A single mutex makes the same aggregate changes +// atomic; production uses PostgreSQL row locks and one transaction. +type TelegramLoginStore struct { + mu sync.RWMutex + + permissions telegramLoginBotPermissionWriter + nextURLID int64 + nextAppID int64 + nextRequestID int64 + nextCodeID int64 + + clientsByID map[string]domain.TelegramLoginClient + clientByBot map[int64]string + allowedURLs map[string]domain.TelegramLoginAllowedURL + nativeApps map[int64]domain.TelegramLoginNativeApp + requests map[int64]domain.TelegramLoginRequest + requestToken map[string]int64 + browserToken map[string]int64 + codes map[int64]domain.TelegramLoginAuthorizationCode + codeByHash map[string]int64 + codeByRequest map[int64]int64 + webAuths map[int64]domain.TelegramLoginWebAuthorization +} + +func NewTelegramLoginStore(permissions telegramLoginBotPermissionWriter) *TelegramLoginStore { + return &TelegramLoginStore{ + permissions: permissions, + clientsByID: make(map[string]domain.TelegramLoginClient), + clientByBot: make(map[int64]string), + allowedURLs: make(map[string]domain.TelegramLoginAllowedURL), + nativeApps: make(map[int64]domain.TelegramLoginNativeApp), + requests: make(map[int64]domain.TelegramLoginRequest), + requestToken: make(map[string]int64), + browserToken: make(map[string]int64), + codes: make(map[int64]domain.TelegramLoginAuthorizationCode), + codeByHash: make(map[string]int64), + codeByRequest: make(map[int64]int64), + webAuths: make(map[int64]domain.TelegramLoginWebAuthorization), + } +} + +func (s *TelegramLoginStore) CreateTelegramLoginClient(_ context.Context, client domain.TelegramLoginClient) (domain.TelegramLoginClient, error) { + if err := client.Validate(); err != nil { + return domain.TelegramLoginClient{}, err + } + s.mu.Lock() + defer s.mu.Unlock() + if _, exists := s.clientByBot[client.BotUserID]; exists { + return domain.TelegramLoginClient{}, domain.ErrTelegramLoginRequestConflict + } + if _, exists := s.clientsByID[client.ClientID]; exists { + return domain.TelegramLoginClient{}, domain.ErrTelegramLoginRequestConflict + } + s.clientsByID[client.ClientID] = client.Clone() + s.clientByBot[client.BotUserID] = client.ClientID + return client.Clone(), nil +} + +func (s *TelegramLoginStore) UpsertTelegramLoginClient(_ context.Context, client domain.TelegramLoginClient) (domain.TelegramLoginClient, error) { + if err := client.Validate(); err != nil { + return domain.TelegramLoginClient{}, err + } + s.mu.Lock() + defer s.mu.Unlock() + if existingID, exists := s.clientByBot[client.BotUserID]; exists && existingID != client.ClientID { + delete(s.clientsByID, existingID) + } + if existing, exists := s.clientsByID[client.ClientID]; exists && existing.BotUserID != client.BotUserID { + return domain.TelegramLoginClient{}, domain.ErrTelegramLoginClientInvalid + } + s.clientsByID[client.ClientID] = client.Clone() + s.clientByBot[client.BotUserID] = client.ClientID + return client.Clone(), nil +} + +func (s *TelegramLoginStore) GetTelegramLoginClient(_ context.Context, clientID string) (domain.TelegramLoginClient, bool, error) { + s.mu.RLock() + client, ok := s.clientsByID[clientID] + s.mu.RUnlock() + return client.Clone(), ok, nil +} + +func (s *TelegramLoginStore) GetTelegramLoginClientByBot(_ context.Context, botUserID int64) (domain.TelegramLoginClient, bool, error) { + s.mu.RLock() + clientID, ok := s.clientByBot[botUserID] + client := s.clientsByID[clientID] + s.mu.RUnlock() + return client.Clone(), ok, nil +} + +func (s *TelegramLoginStore) RotateTelegramLoginClientSecret(_ context.Context, botUserID, expectedVersion int64, secretHash []byte, now time.Time) (domain.TelegramLoginClient, error) { + if botUserID <= 0 || expectedVersion <= 0 || len(secretHash) != 32 { + return domain.TelegramLoginClient{}, domain.ErrTelegramLoginClientInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + clientID, ok := s.clientByBot[botUserID] + if !ok { + return domain.TelegramLoginClient{}, domain.ErrTelegramLoginClientInvalid + } + client := s.clientsByID[clientID] + if client.SecretVersion != expectedVersion { + return domain.TelegramLoginClient{}, domain.ErrTelegramLoginRequestConflict + } + client.SecretVersion++ + client.SecretHash = append([]byte(nil), secretHash...) + client.UpdatedAt = now + s.clientsByID[clientID] = client + return client.Clone(), nil +} + +func (s *TelegramLoginStore) SetTelegramLoginClientSigningAlgorithm(_ context.Context, botUserID int64, algorithm domain.TelegramLoginSigningAlgorithm, now time.Time) (domain.TelegramLoginClient, error) { + if botUserID <= 0 || !algorithm.Valid() { + return domain.TelegramLoginClient{}, domain.ErrTelegramLoginClientInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + clientID, ok := s.clientByBot[botUserID] + if !ok { + return domain.TelegramLoginClient{}, domain.ErrTelegramLoginClientInvalid + } + client := s.clientsByID[clientID] + client.SigningAlgorithm = algorithm + client.UpdatedAt = now + s.clientsByID[clientID] = client + return client.Clone(), nil +} + +func (s *TelegramLoginStore) SetTelegramLoginClientEnabled(_ context.Context, botUserID int64, enabled bool, now time.Time) error { + s.mu.Lock() + defer s.mu.Unlock() + clientID, ok := s.clientByBot[botUserID] + if !ok { + return domain.ErrTelegramLoginClientInvalid + } + client := s.clientsByID[clientID] + client.Enabled = enabled + client.UpdatedAt = now + s.clientsByID[clientID] = client + return nil +} + +func telegramLoginAllowedURLKey(botUserID int64, kind domain.TelegramLoginAllowedURLKind, value string) string { + return strconv.FormatInt(botUserID, 10) + "\x00" + string(kind) + "\x00" + value +} + +func (s *TelegramLoginStore) AddTelegramLoginAllowedURL(_ context.Context, allowed domain.TelegramLoginAllowedURL) (domain.TelegramLoginAllowedURL, error) { + if allowed.BotUserID <= 0 || allowed.NormalizedURL == "" || (allowed.Kind != domain.TelegramLoginAllowedWebOrigin && allowed.Kind != domain.TelegramLoginAllowedRedirectURI) { + return domain.TelegramLoginAllowedURL{}, domain.ErrTelegramLoginURLInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + if _, ok := s.clientByBot[allowed.BotUserID]; !ok { + return domain.TelegramLoginAllowedURL{}, domain.ErrTelegramLoginClientInvalid + } + key := telegramLoginAllowedURLKey(allowed.BotUserID, allowed.Kind, allowed.NormalizedURL) + if existing, ok := s.allowedURLs[key]; ok { + return existing, nil + } + s.nextURLID++ + allowed.ID = s.nextURLID + s.allowedURLs[key] = allowed + return allowed, nil +} + +func (s *TelegramLoginStore) DeleteTelegramLoginAllowedURL(_ context.Context, botUserID int64, kind domain.TelegramLoginAllowedURLKind, normalizedURL string) (bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + key := telegramLoginAllowedURLKey(botUserID, kind, normalizedURL) + if _, ok := s.allowedURLs[key]; !ok { + return false, nil + } + delete(s.allowedURLs, key) + return true, nil +} + +func (s *TelegramLoginStore) ListTelegramLoginAllowedURLs(_ context.Context, botUserID int64) ([]domain.TelegramLoginAllowedURL, error) { + s.mu.RLock() + out := make([]domain.TelegramLoginAllowedURL, 0) + for _, allowed := range s.allowedURLs { + if allowed.BotUserID == botUserID { + out = append(out, allowed) + } + } + s.mu.RUnlock() + sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) + return out, nil +} + +func (s *TelegramLoginStore) IsTelegramLoginURLAllowed(_ context.Context, botUserID int64, kind domain.TelegramLoginAllowedURLKind, normalizedURL string) (bool, error) { + s.mu.RLock() + _, ok := s.allowedURLs[telegramLoginAllowedURLKey(botUserID, kind, normalizedURL)] + s.mu.RUnlock() + return ok, nil +} + +func (s *TelegramLoginStore) UpsertTelegramLoginNativeApp(_ context.Context, app domain.TelegramLoginNativeApp) (domain.TelegramLoginNativeApp, error) { + if err := app.Validate(); err != nil { + return domain.TelegramLoginNativeApp{}, domain.ErrTelegramLoginClientInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + if _, ok := s.clientByBot[app.BotUserID]; !ok { + return domain.TelegramLoginNativeApp{}, domain.ErrTelegramLoginClientInvalid + } + if app.ID == 0 { + for id, existing := range s.nativeApps { + if existing.BotUserID == app.BotUserID && existing.Platform == app.Platform && existing.ApplicationID == app.ApplicationID && existing.VerificationID == app.VerificationID { + app.ID, app.CreatedAt = id, existing.CreatedAt + s.nativeApps[id] = app + return app, nil + } + if existing.BotUserID == app.BotUserID && existing.CallbackURI == app.CallbackURI { + return domain.TelegramLoginNativeApp{}, domain.ErrTelegramLoginRequestConflict + } + } + count := 0 + for _, existing := range s.nativeApps { + if existing.BotUserID == app.BotUserID { + count++ + } + } + if count >= domain.MaxTelegramLoginNativeApps { + return domain.TelegramLoginNativeApp{}, domain.ErrTelegramLoginRequestInvalid + } + s.nextAppID++ + app.ID = s.nextAppID + } else if existing, ok := s.nativeApps[app.ID]; ok && existing.BotUserID != app.BotUserID { + return domain.TelegramLoginNativeApp{}, domain.ErrTelegramLoginClientInvalid + } + s.nativeApps[app.ID] = app + return app, nil +} + +func (s *TelegramLoginStore) DeleteTelegramLoginNativeApp(_ context.Context, botUserID, appID int64) (bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + app, ok := s.nativeApps[appID] + if !ok || app.BotUserID != botUserID { + return false, nil + } + delete(s.nativeApps, appID) + return true, nil +} + +func (s *TelegramLoginStore) ListTelegramLoginNativeApps(_ context.Context, botUserID int64) ([]domain.TelegramLoginNativeApp, error) { + s.mu.RLock() + out := make([]domain.TelegramLoginNativeApp, 0) + for _, app := range s.nativeApps { + if app.BotUserID == botUserID { + out = append(out, app) + } + } + s.mu.RUnlock() + sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) + if len(out) > domain.MaxTelegramLoginNativeApps { + out = out[:domain.MaxTelegramLoginNativeApps] + } + return out, nil +} + +func (s *TelegramLoginStore) CreateTelegramLoginRequest(_ context.Context, request domain.TelegramLoginRequest) (domain.TelegramLoginRequest, error) { + if err := request.Validate(); err != nil { + return domain.TelegramLoginRequest{}, err + } + s.mu.Lock() + defer s.mu.Unlock() + client, ok := s.clientsByID[request.ClientID] + if !ok || client.BotUserID != request.BotUserID || !client.Enabled || client.SigningAlgorithm != request.SigningAlgorithm { + return domain.TelegramLoginRequest{}, domain.ErrTelegramLoginClientDisabled + } + if _, exists := s.requestToken[string(request.RequestTokenHash)]; exists { + return domain.TelegramLoginRequest{}, domain.ErrTelegramLoginRequestConflict + } + if _, exists := s.browserToken[string(request.BrowserTokenHash)]; exists { + return domain.TelegramLoginRequest{}, domain.ErrTelegramLoginRequestConflict + } + s.nextRequestID++ + request.ID = s.nextRequestID + s.requests[request.ID] = request.Clone() + s.requestToken[string(request.RequestTokenHash)] = request.ID + s.browserToken[string(request.BrowserTokenHash)] = request.ID + return request.Clone(), nil +} + +func (s *TelegramLoginStore) GetTelegramLoginRequest(_ context.Context, requestID int64) (domain.TelegramLoginRequest, bool, error) { + s.mu.RLock() + request, ok := s.requests[requestID] + s.mu.RUnlock() + return request.Clone(), ok, nil +} + +func (s *TelegramLoginStore) GetTelegramLoginRequestByTokenHash(_ context.Context, tokenHash []byte) (domain.TelegramLoginRequest, bool, error) { + s.mu.RLock() + id, ok := s.requestToken[string(tokenHash)] + request := s.requests[id] + s.mu.RUnlock() + return request.Clone(), ok, nil +} + +func (s *TelegramLoginStore) GetTelegramLoginRequestByBrowserTokenHash(_ context.Context, tokenHash []byte) (domain.TelegramLoginRequest, bool, error) { + s.mu.RLock() + id, ok := s.browserToken[string(tokenHash)] + request := s.requests[id] + s.mu.RUnlock() + return request.Clone(), ok, nil +} + +func grantedTelegramLoginScopes(request domain.TelegramLoginRequest, approval domain.TelegramLoginApproval) ([]domain.TelegramLoginScope, error) { + if approval.WriteAllowed && !request.Requests(domain.TelegramLoginScopeBotAccess) { + return nil, domain.ErrTelegramLoginScopeInvalid + } + if approval.PhoneShared && !request.Requests(domain.TelegramLoginScopePhone) { + return nil, domain.ErrTelegramLoginScopeInvalid + } + out := make([]domain.TelegramLoginScope, 0, len(request.Scopes)) + for _, scope := range request.Scopes { + if scope == domain.TelegramLoginScopePhone && !approval.PhoneShared { + continue + } + if scope == domain.TelegramLoginScopeBotAccess && !approval.WriteAllowed { + continue + } + out = append(out, scope) + } + return out, nil +} + +func (s *TelegramLoginStore) ApproveTelegramLoginRequest(ctx context.Context, approval domain.TelegramLoginApproval, webAuthorizationHash int64) (domain.TelegramLoginRequest, domain.TelegramLoginWebAuthorization, error) { + if approval.RequestID <= 0 || approval.Identity.UserID <= 0 || webAuthorizationHash == 0 || approval.ApprovedAt.IsZero() { + return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginRequestInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + request, ok := s.requests[approval.RequestID] + if !ok { + return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginRequestInvalid + } + if request.Status != domain.TelegramLoginRequestPending { + return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginRequestConflict + } + if !approval.ApprovedAt.Before(request.ExpiresAt) { + request.Status = domain.TelegramLoginRequestExpired + s.requests[request.ID] = request + return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginRequestExpired + } + client, clientExists := s.clientsByID[request.ClientID] + if !clientExists || !client.Enabled || client.BotUserID != request.BotUserID || client.SigningAlgorithm != request.SigningAlgorithm { + return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginClientDisabled + } + if request.ResponseType == "code" { + _, webAllowed := s.allowedURLs[telegramLoginAllowedURLKey(request.BotUserID, domain.TelegramLoginAllowedRedirectURI, request.RedirectURI)] + if !webAllowed && !(request.Source == domain.TelegramLoginRequestNative && s.nativeCallbackAllowedLocked(request.BotUserID, request.RedirectURI)) { + return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginRedirectNotAllowed + } + } else if request.ResponseType == "post_message" || request.ResponseType == "legacy_url" { + if _, ok := s.allowedURLs[telegramLoginAllowedURLKey(request.BotUserID, domain.TelegramLoginAllowedWebOrigin, request.Origin)]; !ok { + return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginOriginNotAllowed + } + } else { + return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginRequestInvalid + } + if request.InAppOrigin != "" { + if _, ok := s.allowedURLs[telegramLoginAllowedURLKey(request.BotUserID, domain.TelegramLoginAllowedWebOrigin, request.InAppOrigin)]; !ok { + return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginOriginNotAllowed + } + } + if len(request.MatchCodes) > 0 && approval.MatchCode != request.MatchCode { + return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginMatchCodeInvalid + } + scopes, err := grantedTelegramLoginScopes(request, approval) + if err != nil { + return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, err + } + if _, exists := s.webAuths[webAuthorizationHash]; exists { + return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginRequestConflict + } + identity, err := approval.Identity.Sanitized(request.Requests(domain.TelegramLoginScopeProfile), approval.PhoneShared) + if err != nil { + return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, err + } + activeAuthorizations := 0 + for _, authorization := range s.webAuths { + if authorization.UserID == identity.UserID && authorization.RevokedAt.IsZero() { + activeAuthorizations++ + } + } + if activeAuthorizations >= domain.MaxTelegramLoginWebAuthorizations { + return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginAuthorizationsTooMany + } + if approval.WriteAllowed && s.permissions != nil { + if _, err := s.permissions.AllowBotSendMessage(ctx, request.BotUserID, identity.UserID, true); err != nil { + return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, err + } + } + request.Status = domain.TelegramLoginRequestApproved + request.AuthorizedUserID = identity.UserID + request.ProfileName = identity.Name + request.GivenName = identity.GivenName + request.FamilyName = identity.FamilyName + request.PreferredUsername = identity.PreferredUsername + request.Picture = identity.Picture + request.PhoneNumber = identity.PhoneNumber + request.WriteAllowed = approval.WriteAllowed + request.PhoneShared = approval.PhoneShared + request.ApprovedAt = approval.ApprovedAt + s.requests[request.ID] = request.Clone() + web := domain.TelegramLoginWebAuthorization{ + Hash: webAuthorizationHash, + RequestID: request.ID, + UserID: identity.UserID, + BotUserID: request.BotUserID, + Domain: request.Domain, + Browser: request.Browser, + Platform: request.Platform, + IP: request.IP, + Region: request.Region, + Scopes: scopes, + PhoneShared: approval.PhoneShared, + BotAccessGranted: approval.WriteAllowed, + CreatedAt: approval.ApprovedAt, + LastActiveAt: approval.ApprovedAt, + } + s.webAuths[web.Hash] = web.Clone() + return request.Clone(), web.Clone(), nil +} + +func (s *TelegramLoginStore) DeclineTelegramLoginRequest(_ context.Context, requestID, userID int64, now time.Time) (domain.TelegramLoginRequest, error) { + if requestID <= 0 || userID <= 0 || now.IsZero() { + return domain.TelegramLoginRequest{}, domain.ErrTelegramLoginRequestInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + request, ok := s.requests[requestID] + if !ok { + return domain.TelegramLoginRequest{}, domain.ErrTelegramLoginRequestInvalid + } + if request.Status != domain.TelegramLoginRequestPending { + return domain.TelegramLoginRequest{}, domain.ErrTelegramLoginRequestConflict + } + if !now.Before(request.ExpiresAt) { + request.Status = domain.TelegramLoginRequestExpired + s.requests[request.ID] = request + return domain.TelegramLoginRequest{}, domain.ErrTelegramLoginRequestExpired + } + request.Status = domain.TelegramLoginRequestDeclined + request.DeclinedAt = now + s.requests[request.ID] = request.Clone() + return request.Clone(), nil +} + +func (s *TelegramLoginStore) PutTelegramLoginAuthorizationCode(_ context.Context, code domain.TelegramLoginAuthorizationCode) (domain.TelegramLoginAuthorizationCode, error) { + if code.RequestID <= 0 || len(code.CodeHash) != 32 || len(code.SealedCode) < 32 || len(code.SealNonce) < 12 || code.SealKeyID == "" || code.IssuedAt.IsZero() || !code.ExpiresAt.After(code.IssuedAt) { + return domain.TelegramLoginAuthorizationCode{}, domain.ErrTelegramLoginCodeInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + request, ok := s.requests[code.RequestID] + if !ok || request.Status != domain.TelegramLoginRequestApproved { + return domain.TelegramLoginAuthorizationCode{}, domain.ErrTelegramLoginRequestConflict + } + client, clientExists := s.clientsByID[request.ClientID] + if !clientExists || !client.Enabled || client.BotUserID != request.BotUserID || client.SigningAlgorithm != request.SigningAlgorithm { + return domain.TelegramLoginAuthorizationCode{}, domain.ErrTelegramLoginClientDisabled + } + switch request.ResponseType { + case "code": + _, allowed := s.allowedURLs[telegramLoginAllowedURLKey(request.BotUserID, domain.TelegramLoginAllowedRedirectURI, request.RedirectURI)] + if !allowed && !(request.Source == domain.TelegramLoginRequestNative && s.nativeCallbackAllowedLocked(request.BotUserID, request.RedirectURI)) { + return domain.TelegramLoginAuthorizationCode{}, domain.ErrTelegramLoginRedirectNotAllowed + } + case "post_message": + if _, allowed := s.allowedURLs[telegramLoginAllowedURLKey(request.BotUserID, domain.TelegramLoginAllowedWebOrigin, request.Origin)]; !allowed { + return domain.TelegramLoginAuthorizationCode{}, domain.ErrTelegramLoginOriginNotAllowed + } + default: + return domain.TelegramLoginAuthorizationCode{}, domain.ErrTelegramLoginRequestConflict + } + web, active := s.webAuthByRequestLocked(request.ID) + if !active || !web.RevokedAt.IsZero() { + return domain.TelegramLoginAuthorizationCode{}, domain.ErrTelegramLoginRequestConflict + } + if id, exists := s.codeByRequest[code.RequestID]; exists { + return s.codes[id].Clone(), nil + } + if _, exists := s.codeByHash[string(code.CodeHash)]; exists { + return domain.TelegramLoginAuthorizationCode{}, domain.ErrTelegramLoginRequestConflict + } + s.nextCodeID++ + code.ID = s.nextCodeID + s.codes[code.ID] = code.Clone() + s.codeByHash[string(code.CodeHash)] = code.ID + s.codeByRequest[code.RequestID] = code.ID + return code.Clone(), nil +} + +func (s *TelegramLoginStore) GetTelegramLoginAuthorizationCodeByRequest(_ context.Context, requestID int64) (domain.TelegramLoginAuthorizationCode, bool, error) { + s.mu.RLock() + id, ok := s.codeByRequest[requestID] + code := s.codes[id] + s.mu.RUnlock() + return code.Clone(), ok, nil +} + +func (s *TelegramLoginStore) GetTelegramLoginAuthorizationCodeByHash(_ context.Context, codeHash []byte) (domain.TelegramLoginAuthorizationCode, bool, error) { + s.mu.RLock() + id, ok := s.codeByHash[string(codeHash)] + code := s.codes[id] + s.mu.RUnlock() + return code.Clone(), ok, nil +} + +func (s *TelegramLoginStore) ConsumeTelegramLoginAuthorizationCode(_ context.Context, exchange domain.TelegramLoginCodeExchange) (domain.TelegramLoginAuthorizationCode, domain.TelegramLoginRequest, domain.TelegramLoginWebAuthorization, error) { + if len(exchange.CodeHash) != 32 || exchange.ClientID == "" || exchange.ClientSecretVersion <= 0 || exchange.RedirectURI == "" || exchange.CodeChallenge == "" || exchange.Now.IsZero() { + return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + id, ok := s.codeByHash[string(exchange.CodeHash)] + if !ok { + return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeInvalid + } + code := s.codes[id] + if !code.ConsumedAt.IsZero() { + return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeConsumed + } + if !exchange.Now.Before(code.ExpiresAt) { + return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeInvalid + } + request := s.requests[code.RequestID] + client, clientExists := s.clientsByID[exchange.ClientID] + if !clientExists || !client.Enabled || client.SecretVersion != exchange.ClientSecretVersion || request.ResponseType != "code" || request.ClientID != exchange.ClientID || request.RedirectURI != exchange.RedirectURI || request.CodeChallenge != exchange.CodeChallenge { + return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeInvalid + } + _, webAllowed := s.allowedURLs[telegramLoginAllowedURLKey(request.BotUserID, domain.TelegramLoginAllowedRedirectURI, request.RedirectURI)] + if !webAllowed && !(request.Source == domain.TelegramLoginRequestNative && s.nativeCallbackAllowedLocked(request.BotUserID, request.RedirectURI)) { + return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeInvalid + } + web, exists := s.webAuthByRequestLocked(code.RequestID) + if request.Status != domain.TelegramLoginRequestApproved || !exists || !web.RevokedAt.IsZero() { + return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeInvalid + } + code.ConsumedAt = exchange.Now + web.LastActiveAt = exchange.Now + s.codes[id] = code.Clone() + s.webAuths[web.Hash] = web.Clone() + return code.Clone(), request.Clone(), web.Clone(), nil +} + +func (s *TelegramLoginStore) ConsumeTelegramLoginDirectToken(_ context.Context, tokenHash []byte, origin string, now time.Time) (domain.TelegramLoginAuthorizationCode, domain.TelegramLoginRequest, domain.TelegramLoginWebAuthorization, error) { + if len(tokenHash) != 32 || origin == "" || now.IsZero() { + return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + id, ok := s.codeByHash[string(tokenHash)] + if !ok { + return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeInvalid + } + code := s.codes[id] + if !code.ConsumedAt.IsZero() { + return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeConsumed + } + if !now.Before(code.ExpiresAt) { + return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeInvalid + } + request := s.requests[code.RequestID] + client, clientExists := s.clientsByID[request.ClientID] + if !clientExists || !client.Enabled || client.BotUserID != request.BotUserID || + request.Status != domain.TelegramLoginRequestApproved || request.Source != domain.TelegramLoginRequestMiniApp || + request.ResponseType != "post_message" || request.Origin != origin || request.InAppOrigin != origin { + return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeInvalid + } + if _, allowed := s.allowedURLs[telegramLoginAllowedURLKey(request.BotUserID, domain.TelegramLoginAllowedWebOrigin, origin)]; !allowed { + return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeInvalid + } + web, exists := s.webAuthByRequestLocked(code.RequestID) + if !exists || !web.RevokedAt.IsZero() { + return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeInvalid + } + code.ConsumedAt = now + web.LastActiveAt = now + s.codes[id] = code.Clone() + s.webAuths[web.Hash] = web.Clone() + return code.Clone(), request.Clone(), web.Clone(), nil +} + +func (s *TelegramLoginStore) webAuthByRequestLocked(requestID int64) (domain.TelegramLoginWebAuthorization, bool) { + for _, web := range s.webAuths { + if web.RequestID == requestID { + return web, true + } + } + return domain.TelegramLoginWebAuthorization{}, false +} + +func (s *TelegramLoginStore) nativeCallbackAllowedLocked(botUserID int64, callbackURI string) bool { + for _, app := range s.nativeApps { + if app.BotUserID == botUserID && app.Enabled && app.CallbackURI == callbackURI { + return true + } + } + return false +} + +func (s *TelegramLoginStore) ListTelegramLoginWebAuthorizations(_ context.Context, userID int64) ([]domain.TelegramLoginWebAuthorization, error) { + s.mu.RLock() + out := make([]domain.TelegramLoginWebAuthorization, 0, min(len(s.webAuths), domain.MaxTelegramLoginWebAuthorizations)) + for _, web := range s.webAuths { + if web.UserID == userID && web.RevokedAt.IsZero() { + out = append(out, web.Clone()) + } + } + s.mu.RUnlock() + sort.Slice(out, func(i, j int) bool { + if out[i].LastActiveAt.Equal(out[j].LastActiveAt) { + return out[i].Hash > out[j].Hash + } + return out[i].LastActiveAt.After(out[j].LastActiveAt) + }) + if len(out) > domain.MaxTelegramLoginWebAuthorizations { + out = out[:domain.MaxTelegramLoginWebAuthorizations] + } + return out, nil +} + +func (s *TelegramLoginStore) RevokeTelegramLoginWebAuthorization(_ context.Context, userID, hash int64, now time.Time) (bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + web, ok := s.webAuths[hash] + if !ok || web.UserID != userID || !web.RevokedAt.IsZero() { + return false, nil + } + web.RevokedAt = now + s.webAuths[hash] = web + return true, nil +} + +func (s *TelegramLoginStore) RevokeAllTelegramLoginWebAuthorizations(_ context.Context, userID int64, now time.Time) (int64, error) { + s.mu.Lock() + defer s.mu.Unlock() + var count int64 + for hash, web := range s.webAuths { + if web.UserID == userID && web.RevokedAt.IsZero() { + web.RevokedAt = now + s.webAuths[hash] = web + count++ + } + } + return count, nil +} + +func (s *TelegramLoginStore) DeleteExpiredTelegramLoginArtifacts(_ context.Context, before time.Time, limit int) (int64, error) { + if limit <= 0 || limit > 1000 { + return 0, domain.ErrTelegramLoginRequestInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + var deleted int64 + for id, code := range s.codes { + if deleted >= int64(limit) { + break + } + if code.ExpiresAt.Before(before) || (!code.ConsumedAt.IsZero() && code.ConsumedAt.Before(before)) { + delete(s.codes, id) + delete(s.codeByHash, string(code.CodeHash)) + delete(s.codeByRequest, code.RequestID) + deleted++ + } + } + for id, request := range s.requests { + if deleted >= int64(limit) { + break + } + deleteRequest := (request.Status == domain.TelegramLoginRequestPending || request.Status == domain.TelegramLoginRequestDeclined || request.Status == domain.TelegramLoginRequestExpired) && request.ExpiresAt.Before(before) + var revokedWebHash int64 + if request.Status == domain.TelegramLoginRequestApproved && !request.ApprovedAt.IsZero() && request.ApprovedAt.Before(before) { + // Approved requests remain the immutable claim snapshot behind an active + // web authorization. They may only be collected after the grant itself + // was revoked and every exchange code has left the retention window. + for hash, web := range s.webAuths { + if web.RequestID == id && !web.RevokedAt.IsZero() && web.RevokedAt.Before(before) { + deleteRequest = true + revokedWebHash = hash + break + } + } + if _, hasCode := s.codeByRequest[id]; hasCode { + deleteRequest = false + } + } + if !deleteRequest { + continue + } + delete(s.requests, id) + delete(s.requestToken, string(request.RequestTokenHash)) + delete(s.browserToken, string(request.BrowserTokenHash)) + if revokedWebHash != 0 { + delete(s.webAuths, revokedWebHash) + } + deleted++ + } + return deleted, nil +} diff --git a/internal/store/memory/telegram_login_test.go b/internal/store/memory/telegram_login_test.go new file mode 100644 index 00000000..0e02d27d --- /dev/null +++ b/internal/store/memory/telegram_login_test.go @@ -0,0 +1,320 @@ +package memory + +import ( + "context" + "crypto/sha256" + "errors" + "sync" + "testing" + "time" + + "telesrv/internal/domain" +) + +type telegramLoginPermissionRecorder struct { + mu sync.Mutex + grants map[[2]int64]int +} + +func (r *telegramLoginPermissionRecorder) AllowBotSendMessage(_ context.Context, botUserID, userID int64, _ bool) (bool, error) { + r.mu.Lock() + defer r.mu.Unlock() + if r.grants == nil { + r.grants = make(map[[2]int64]int) + } + key := [2]int64{botUserID, userID} + created := r.grants[key] == 0 + r.grants[key]++ + return created, nil +} + +func telegramLoginTestHash(value string) []byte { + sum := sha256.Sum256([]byte(value)) + return sum[:] +} + +func seedTelegramLoginRequest(t *testing.T, s *TelegramLoginStore, now time.Time) domain.TelegramLoginRequest { + t.Helper() + ctx := context.Background() + client := domain.TelegramLoginClient{ + BotUserID: 9001, + ClientID: "9001", + SecretHash: telegramLoginTestHash("client-secret"), + SecretVersion: 1, + SigningAlgorithm: domain.TelegramLoginSigningRS256, + Enabled: true, + CreatedAt: now, + UpdatedAt: now, + } + if _, err := s.UpsertTelegramLoginClient(ctx, client); err != nil { + t.Fatalf("UpsertTelegramLoginClient: %v", err) + } + if _, err := s.AddTelegramLoginAllowedURL(ctx, domain.TelegramLoginAllowedURL{ + BotUserID: client.BotUserID, Kind: domain.TelegramLoginAllowedRedirectURI, + NormalizedURL: "https://rp.example/callback", CreatedAt: now, + }); err != nil { + t.Fatalf("AddTelegramLoginAllowedURL: %v", err) + } + request := domain.TelegramLoginRequest{ + RequestTokenHash: telegramLoginTestHash("request-token"), + BrowserTokenHash: telegramLoginTestHash("browser-token"), + BotUserID: client.BotUserID, + ClientID: client.ClientID, + SigningAlgorithm: client.SigningAlgorithm, + Source: domain.TelegramLoginRequestWeb, + ResponseType: "code", + RedirectURI: "https://rp.example/callback", + Origin: "https://rp.example", + Domain: "rp.example", + Scopes: []domain.TelegramLoginScope{domain.TelegramLoginScopeOpenID, domain.TelegramLoginScopeProfile, domain.TelegramLoginScopePhone, domain.TelegramLoginScopeBotAccess}, + State: "state", + Nonce: "nonce", + CodeChallenge: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + CodeChallengeMethod: "S256", + Browser: "Firefox", + Platform: "Windows", + IP: "192.0.2.10", + Region: "Test Region", + MatchCodes: []string{"🟢", "🔵", "🟠"}, + MatchCode: "🔵", + MatchCodesFirst: true, + Status: domain.TelegramLoginRequestPending, + CreatedAt: now, + ExpiresAt: now.Add(5 * time.Minute), + } + created, err := s.CreateTelegramLoginRequest(ctx, request) + if err != nil { + t.Fatalf("CreateTelegramLoginRequest: %v", err) + } + return created +} + +func approveTelegramLoginRequest(t *testing.T, s *TelegramLoginStore, request domain.TelegramLoginRequest, now time.Time) (domain.TelegramLoginRequest, domain.TelegramLoginWebAuthorization) { + t.Helper() + approved, web, err := s.ApproveTelegramLoginRequest(context.Background(), domain.TelegramLoginApproval{ + RequestID: request.ID, + Identity: domain.TelegramLoginIdentitySnapshot{ + UserID: 42, Name: "Alice Example", GivenName: "Alice", FamilyName: "Example", + PreferredUsername: "alice", Picture: "https://oauth.example/userpic/42", + }, + WriteAllowed: true, + PhoneShared: false, + MatchCode: request.MatchCode, + ApprovedAt: now, + }, 7000+request.ID) + if err != nil { + t.Fatalf("ApproveTelegramLoginRequest: %v", err) + } + return approved, web +} + +func TestTelegramLoginApproveIsAtomicAndShrinksConsent(t *testing.T) { + now := time.Unix(1_780_000_000, 0) + permissions := &telegramLoginPermissionRecorder{} + s := NewTelegramLoginStore(permissions) + request := seedTelegramLoginRequest(t, s, now) + approved, web := approveTelegramLoginRequest(t, s, request, now.Add(time.Second)) + if approved.Status != domain.TelegramLoginRequestApproved || approved.AuthorizedUserID != 42 { + t.Fatalf("approved request = %#v", approved) + } + if web.PhoneShared || web.BotAccessGranted != true { + t.Fatalf("web consent = %#v", web) + } + if len(web.Scopes) != 3 || web.Scopes[0] != domain.TelegramLoginScopeOpenID || web.Scopes[1] != domain.TelegramLoginScopeProfile || web.Scopes[2] != domain.TelegramLoginScopeBotAccess { + t.Fatalf("granted scopes = %#v", web.Scopes) + } + permissions.mu.Lock() + grants := permissions.grants[[2]int64{9001, 42}] + permissions.mu.Unlock() + if grants != 1 { + t.Fatalf("bot permission grants = %d, want 1", grants) + } +} + +func TestTelegramLoginAcceptDeclineRaceHasOneTerminalState(t *testing.T) { + now := time.Unix(1_780_000_000, 0) + s := NewTelegramLoginStore(nil) + request := seedTelegramLoginRequest(t, s, now) + start := make(chan struct{}) + errs := make(chan error, 2) + go func() { + <-start + _, _, err := s.ApproveTelegramLoginRequest(context.Background(), domain.TelegramLoginApproval{ + RequestID: request.ID, + Identity: domain.TelegramLoginIdentitySnapshot{UserID: 42, Name: "Alice", GivenName: "Alice"}, + MatchCode: request.MatchCode, ApprovedAt: now.Add(time.Second), + }, 7001) + errs <- err + }() + go func() { + <-start + _, err := s.DeclineTelegramLoginRequest(context.Background(), request.ID, 42, now.Add(time.Second)) + errs <- err + }() + close(start) + var success, conflict int + for range 2 { + err := <-errs + switch { + case err == nil: + success++ + case errors.Is(err, domain.ErrTelegramLoginRequestConflict): + conflict++ + default: + t.Fatalf("unexpected race error: %v", err) + } + } + if success != 1 || conflict != 1 { + t.Fatalf("success=%d conflict=%d, want 1/1", success, conflict) + } +} + +func TestTelegramLoginAuthorizationCodeSingleConsumeAndRevocation(t *testing.T) { + now := time.Unix(1_780_000_000, 0) + s := NewTelegramLoginStore(nil) + request := seedTelegramLoginRequest(t, s, now) + approveTelegramLoginRequest(t, s, request, now.Add(time.Second)) + code := domain.TelegramLoginAuthorizationCode{ + RequestID: request.ID, + CodeHash: telegramLoginTestHash("authorization-code"), + SealedCode: append(make([]byte, 32), 1), + SealNonce: make([]byte, 12), + SealKeyID: "test-key", + IssuedAt: now.Add(2 * time.Second), + ExpiresAt: now.Add(time.Minute), + } + if _, err := s.PutTelegramLoginAuthorizationCode(context.Background(), code); err != nil { + t.Fatalf("PutTelegramLoginAuthorizationCode: %v", err) + } + + start := make(chan struct{}) + errs := make(chan error, 8) + for range 8 { + go func() { + <-start + _, _, _, err := s.ConsumeTelegramLoginAuthorizationCode(context.Background(), domain.TelegramLoginCodeExchange{ + CodeHash: code.CodeHash, ClientID: request.ClientID, ClientSecretVersion: 1, + RedirectURI: request.RedirectURI, CodeChallenge: request.CodeChallenge, Now: now.Add(3 * time.Second), + }) + errs <- err + }() + } + close(start) + var success, consumed int + for range 8 { + err := <-errs + switch { + case err == nil: + success++ + case errors.Is(err, domain.ErrTelegramLoginCodeConsumed): + consumed++ + default: + t.Fatalf("unexpected consume error: %v", err) + } + } + if success != 1 || consumed != 7 { + t.Fatalf("success=%d consumed=%d, want 1/7", success, consumed) + } + + request2 := request.Clone() + request2.ID = 0 + request2.RequestTokenHash = telegramLoginTestHash("request-token-2") + request2.BrowserTokenHash = telegramLoginTestHash("browser-token-2") + request2, err := s.CreateTelegramLoginRequest(context.Background(), request2) + if err != nil { + t.Fatalf("Create second request: %v", err) + } + _, web2 := approveTelegramLoginRequest(t, s, request2, now.Add(4*time.Second)) + code2 := code.Clone() + code2.ID = 0 + code2.RequestID = request2.ID + code2.CodeHash = telegramLoginTestHash("authorization-code-2") + if _, err := s.PutTelegramLoginAuthorizationCode(context.Background(), code2); err != nil { + t.Fatalf("Put second code: %v", err) + } + if revoked, err := s.RevokeTelegramLoginWebAuthorization(context.Background(), web2.UserID, web2.Hash, now.Add(5*time.Second)); err != nil || !revoked { + t.Fatalf("RevokeTelegramLoginWebAuthorization = %v,%v", revoked, err) + } + if _, _, _, err := s.ConsumeTelegramLoginAuthorizationCode(context.Background(), domain.TelegramLoginCodeExchange{ + CodeHash: code2.CodeHash, ClientID: request2.ClientID, ClientSecretVersion: 1, + RedirectURI: request2.RedirectURI, CodeChallenge: request2.CodeChallenge, Now: now.Add(6 * time.Second), + }); !errors.Is(err, domain.ErrTelegramLoginCodeInvalid) { + t.Fatalf("consume after revoke error = %v, want code invalid", err) + } +} + +func TestTelegramLoginRetentionPreservesActiveAndReferencedApprovals(t *testing.T) { + ctx := context.Background() + now := time.Unix(1_780_000_000, 0) + before := now.Add(24 * time.Hour) + s := NewTelegramLoginStore(nil) + + active := seedTelegramLoginRequest(t, s, now) + _, activeWeb := approveTelegramLoginRequest(t, s, active, now.Add(time.Second)) + + revoked := active.Clone() + revoked.ID = 0 + revoked.RequestTokenHash = telegramLoginTestHash("retention-revoked-request") + revoked.BrowserTokenHash = telegramLoginTestHash("retention-revoked-browser") + revoked.Status = domain.TelegramLoginRequestPending + revoked.AuthorizedUserID = 0 + revoked.ProfileName, revoked.GivenName, revoked.FamilyName = "", "", "" + revoked.PreferredUsername, revoked.Picture, revoked.PhoneNumber = "", "", "" + revoked.WriteAllowed, revoked.PhoneShared = false, false + revoked.ApprovedAt = time.Time{} + revoked, err := s.CreateTelegramLoginRequest(ctx, revoked) + if err != nil { + t.Fatalf("create revoked request: %v", err) + } + _, revokedWeb := approveTelegramLoginRequest(t, s, revoked, now.Add(2*time.Second)) + if ok, err := s.RevokeTelegramLoginWebAuthorization(ctx, revokedWeb.UserID, revokedWeb.Hash, now.Add(3*time.Second)); err != nil || !ok { + t.Fatalf("revoke old authorization = %v,%v", ok, err) + } + + referenced := revoked.Clone() + referenced.ID = 0 + referenced.RequestTokenHash = telegramLoginTestHash("retention-referenced-request") + referenced.BrowserTokenHash = telegramLoginTestHash("retention-referenced-browser") + referenced.Status = domain.TelegramLoginRequestPending + referenced.AuthorizedUserID = 0 + referenced.ProfileName, referenced.GivenName, referenced.FamilyName = "", "", "" + referenced.PreferredUsername, referenced.Picture, referenced.PhoneNumber = "", "", "" + referenced.WriteAllowed, referenced.PhoneShared = false, false + referenced.ApprovedAt = time.Time{} + referenced, err = s.CreateTelegramLoginRequest(ctx, referenced) + if err != nil { + t.Fatalf("create referenced request: %v", err) + } + _, referencedWeb := approveTelegramLoginRequest(t, s, referenced, now.Add(4*time.Second)) + if _, err := s.PutTelegramLoginAuthorizationCode(ctx, domain.TelegramLoginAuthorizationCode{ + RequestID: referenced.ID, CodeHash: telegramLoginTestHash("retention-live-code"), + SealedCode: append(make([]byte, 32), 1), SealNonce: make([]byte, 12), SealKeyID: "test-key", + IssuedAt: before.Add(time.Hour), ExpiresAt: before.Add(2 * time.Hour), + }); err != nil { + t.Fatalf("put retained code: %v", err) + } + if ok, err := s.RevokeTelegramLoginWebAuthorization(ctx, referencedWeb.UserID, referencedWeb.Hash, now.Add(5*time.Second)); err != nil || !ok { + t.Fatalf("revoke referenced authorization = %v,%v", ok, err) + } + + deleted, err := s.DeleteExpiredTelegramLoginArtifacts(ctx, before, 100) + if err != nil { + t.Fatalf("delete expired artifacts: %v", err) + } + if deleted != 1 { + t.Fatalf("deleted = %d, want revoked request only", deleted) + } + if _, found, _ := s.GetTelegramLoginRequest(ctx, active.ID); !found { + t.Fatal("active authorization request was deleted") + } + if _, found, _ := s.GetTelegramLoginRequest(ctx, referenced.ID); !found { + t.Fatal("request with retained code was deleted") + } + if _, found, _ := s.GetTelegramLoginRequest(ctx, revoked.ID); found { + t.Fatal("old revoked authorization request was retained") + } + listed, err := s.ListTelegramLoginWebAuthorizations(ctx, activeWeb.UserID) + if err != nil || len(listed) != 1 || listed[0].Hash != activeWeb.Hash { + t.Fatalf("active authorizations after retention = %#v, %v", listed, err) + } +} diff --git a/internal/store/postgres/star_gift_lifecycle_migration_integration_test.go b/internal/store/postgres/star_gift_lifecycle_migration_integration_test.go index 8fe4e5c6..a66c4fb5 100644 --- a/internal/store/postgres/star_gift_lifecycle_migration_integration_test.go +++ b/internal/store/postgres/star_gift_lifecycle_migration_integration_test.go @@ -14,7 +14,7 @@ func TestStarGiftLifecycleMigrationsApply(t *testing.T) { if err != nil { t.Fatalf("migrate star gift lifecycle schema: %v", err) } - if status.Dirty || status.Empty || status.Version != 124 { - t.Fatalf("migration status = %+v, want clean version 124", status) + if status.Dirty || status.Empty || status.Version != 125 { + t.Fatalf("migration status = %+v, want clean version 125", status) } } diff --git a/internal/store/postgres/telegram_login.go b/internal/store/postgres/telegram_login.go new file mode 100644 index 00000000..58f4fd8a --- /dev/null +++ b/internal/store/postgres/telegram_login.go @@ -0,0 +1,1106 @@ +package postgres + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/jackc/pgerrcode" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + + "telesrv/internal/domain" + "telesrv/internal/store/postgres/sqlcgen" +) + +type TelegramLoginStore struct { + db sqlcgen.DBTX +} + +func NewTelegramLoginStore(db sqlcgen.DBTX) *TelegramLoginStore { + return &TelegramLoginStore{db: db} +} + +type telegramLoginRowScanner interface { + Scan(dest ...any) error +} + +const telegramLoginClientColumns = `bot_user_id, client_id, client_secret_hash, secret_version, signing_algorithm, enabled, created_at, updated_at` + +func scanTelegramLoginClient(row telegramLoginRowScanner) (domain.TelegramLoginClient, error) { + var client domain.TelegramLoginClient + var algorithm string + if err := row.Scan(&client.BotUserID, &client.ClientID, &client.SecretHash, &client.SecretVersion, &algorithm, &client.Enabled, &client.CreatedAt, &client.UpdatedAt); err != nil { + return domain.TelegramLoginClient{}, err + } + client.SigningAlgorithm = domain.TelegramLoginSigningAlgorithm(algorithm) + return client, nil +} + +func (s *TelegramLoginStore) CreateTelegramLoginClient(ctx context.Context, client domain.TelegramLoginClient) (domain.TelegramLoginClient, error) { + if err := client.Validate(); err != nil { + return domain.TelegramLoginClient{}, err + } + createdAt := client.CreatedAt + if createdAt.IsZero() { + createdAt = time.Now().UTC() + } + updatedAt := client.UpdatedAt + if updatedAt.IsZero() { + updatedAt = createdAt + } + client, err := scanTelegramLoginClient(s.db.QueryRow(ctx, ` +INSERT INTO bot_login_clients ( + bot_user_id, client_id, client_secret_hash, secret_version, signing_algorithm, + enabled, created_at, updated_at +) VALUES ($1,$2,$3,$4,$5,$6,$7,$8) +RETURNING `+telegramLoginClientColumns, + client.BotUserID, client.ClientID, client.SecretHash, client.SecretVersion, + string(client.SigningAlgorithm), client.Enabled, createdAt, updatedAt)) + if err != nil { + return domain.TelegramLoginClient{}, mapTelegramLoginWriteError("create telegram login client", err) + } + return client, nil +} + +func (s *TelegramLoginStore) UpsertTelegramLoginClient(ctx context.Context, client domain.TelegramLoginClient) (domain.TelegramLoginClient, error) { + if err := client.Validate(); err != nil { + return domain.TelegramLoginClient{}, err + } + createdAt := client.CreatedAt + if createdAt.IsZero() { + createdAt = time.Now().UTC() + } + updatedAt := client.UpdatedAt + if updatedAt.IsZero() { + updatedAt = createdAt + } + row := s.db.QueryRow(ctx, ` +INSERT INTO bot_login_clients ( + bot_user_id, client_id, client_secret_hash, secret_version, signing_algorithm, + enabled, created_at, updated_at +) VALUES ($1,$2,$3,$4,$5,$6,$7,$8) +ON CONFLICT (bot_user_id) DO UPDATE SET + client_id = EXCLUDED.client_id, + client_secret_hash = EXCLUDED.client_secret_hash, + secret_version = EXCLUDED.secret_version, + signing_algorithm = EXCLUDED.signing_algorithm, + enabled = EXCLUDED.enabled, + updated_at = EXCLUDED.updated_at +RETURNING `+telegramLoginClientColumns, + client.BotUserID, client.ClientID, client.SecretHash, client.SecretVersion, + string(client.SigningAlgorithm), client.Enabled, createdAt, updatedAt) + out, err := scanTelegramLoginClient(row) + if err != nil { + return domain.TelegramLoginClient{}, mapTelegramLoginWriteError("upsert telegram login client", err) + } + return out, nil +} + +func (s *TelegramLoginStore) GetTelegramLoginClient(ctx context.Context, clientID string) (domain.TelegramLoginClient, bool, error) { + client, err := scanTelegramLoginClient(s.db.QueryRow(ctx, `SELECT `+telegramLoginClientColumns+` FROM bot_login_clients WHERE client_id = $1`, clientID)) + if errors.Is(err, pgx.ErrNoRows) { + return domain.TelegramLoginClient{}, false, nil + } + if err != nil { + return domain.TelegramLoginClient{}, false, fmt.Errorf("get telegram login client: %w", err) + } + return client, true, nil +} + +func (s *TelegramLoginStore) GetTelegramLoginClientByBot(ctx context.Context, botUserID int64) (domain.TelegramLoginClient, bool, error) { + client, err := scanTelegramLoginClient(s.db.QueryRow(ctx, `SELECT `+telegramLoginClientColumns+` FROM bot_login_clients WHERE bot_user_id = $1`, botUserID)) + if errors.Is(err, pgx.ErrNoRows) { + return domain.TelegramLoginClient{}, false, nil + } + if err != nil { + return domain.TelegramLoginClient{}, false, fmt.Errorf("get telegram login client by bot: %w", err) + } + return client, true, nil +} + +func (s *TelegramLoginStore) RotateTelegramLoginClientSecret(ctx context.Context, botUserID, expectedVersion int64, secretHash []byte, now time.Time) (domain.TelegramLoginClient, error) { + if botUserID <= 0 || expectedVersion <= 0 || len(secretHash) != 32 { + return domain.TelegramLoginClient{}, domain.ErrTelegramLoginClientInvalid + } + client, err := scanTelegramLoginClient(s.db.QueryRow(ctx, ` +UPDATE bot_login_clients +SET client_secret_hash = $3, secret_version = secret_version + 1, updated_at = $4 +WHERE bot_user_id = $1 AND secret_version = $2 +RETURNING `+telegramLoginClientColumns, botUserID, expectedVersion, secretHash, now)) + if errors.Is(err, pgx.ErrNoRows) { + return domain.TelegramLoginClient{}, domain.ErrTelegramLoginRequestConflict + } + if err != nil { + return domain.TelegramLoginClient{}, fmt.Errorf("rotate telegram login client secret: %w", err) + } + return client, nil +} + +func (s *TelegramLoginStore) SetTelegramLoginClientSigningAlgorithm(ctx context.Context, botUserID int64, algorithm domain.TelegramLoginSigningAlgorithm, now time.Time) (domain.TelegramLoginClient, error) { + if botUserID <= 0 || !algorithm.Valid() { + return domain.TelegramLoginClient{}, domain.ErrTelegramLoginClientInvalid + } + client, err := scanTelegramLoginClient(s.db.QueryRow(ctx, ` +UPDATE bot_login_clients SET signing_algorithm = $2, updated_at = $3 +WHERE bot_user_id = $1 +RETURNING `+telegramLoginClientColumns, botUserID, string(algorithm), now)) + if errors.Is(err, pgx.ErrNoRows) { + return domain.TelegramLoginClient{}, domain.ErrTelegramLoginClientInvalid + } + if err != nil { + return domain.TelegramLoginClient{}, fmt.Errorf("set telegram login client signing algorithm: %w", err) + } + return client, nil +} + +func (s *TelegramLoginStore) SetTelegramLoginClientEnabled(ctx context.Context, botUserID int64, enabled bool, now time.Time) error { + tag, err := s.db.Exec(ctx, `UPDATE bot_login_clients SET enabled = $2, updated_at = $3 WHERE bot_user_id = $1`, botUserID, enabled, now) + if err != nil { + return fmt.Errorf("set telegram login client enabled: %w", err) + } + if tag.RowsAffected() == 0 { + return domain.ErrTelegramLoginClientInvalid + } + return nil +} + +func scanTelegramLoginAllowedURL(row telegramLoginRowScanner) (domain.TelegramLoginAllowedURL, error) { + var allowed domain.TelegramLoginAllowedURL + var kind string + if err := row.Scan(&allowed.ID, &allowed.BotUserID, &kind, &allowed.NormalizedURL, &allowed.CreatedAt); err != nil { + return domain.TelegramLoginAllowedURL{}, err + } + allowed.Kind = domain.TelegramLoginAllowedURLKind(kind) + return allowed, nil +} + +func (s *TelegramLoginStore) AddTelegramLoginAllowedURL(ctx context.Context, allowed domain.TelegramLoginAllowedURL) (domain.TelegramLoginAllowedURL, error) { + if allowed.BotUserID <= 0 || allowed.NormalizedURL == "" || (allowed.Kind != domain.TelegramLoginAllowedWebOrigin && allowed.Kind != domain.TelegramLoginAllowedRedirectURI) { + return domain.TelegramLoginAllowedURL{}, domain.ErrTelegramLoginURLInvalid + } + createdAt := allowed.CreatedAt + if createdAt.IsZero() { + createdAt = time.Now().UTC() + } + row := s.db.QueryRow(ctx, ` +INSERT INTO bot_login_allowed_urls (bot_user_id, kind, normalized_url, created_at) +VALUES ($1,$2,$3,$4) +ON CONFLICT (bot_user_id, kind, normalized_url) DO UPDATE +SET normalized_url = EXCLUDED.normalized_url +RETURNING id, bot_user_id, kind, normalized_url, created_at`, + allowed.BotUserID, string(allowed.Kind), allowed.NormalizedURL, createdAt) + out, err := scanTelegramLoginAllowedURL(row) + if err != nil { + return domain.TelegramLoginAllowedURL{}, mapTelegramLoginWriteError("add telegram login allowed url", err) + } + return out, nil +} + +func (s *TelegramLoginStore) DeleteTelegramLoginAllowedURL(ctx context.Context, botUserID int64, kind domain.TelegramLoginAllowedURLKind, normalizedURL string) (bool, error) { + beginner, ok := s.db.(txBeginner) + if !ok { + return false, fmt.Errorf("delete telegram login allowed url: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return false, fmt.Errorf("delete telegram login allowed url: begin: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + // Approval and code consumption lock this same client row before their + // final allow-list recheck. Configuration removal therefore serializes + // with those transitions across every server instance. + var exists bool + if err := tx.QueryRow(ctx, `SELECT true FROM bot_login_clients WHERE bot_user_id = $1 FOR UPDATE`, botUserID).Scan(&exists); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return false, domain.ErrTelegramLoginClientInvalid + } + return false, fmt.Errorf("delete telegram login allowed url: lock client: %w", err) + } + tag, err := tx.Exec(ctx, `DELETE FROM bot_login_allowed_urls WHERE bot_user_id = $1 AND kind = $2 AND normalized_url = $3`, botUserID, string(kind), normalizedURL) + if err != nil { + return false, fmt.Errorf("delete telegram login allowed url: %w", err) + } + if err := tx.Commit(ctx); err != nil { + return false, fmt.Errorf("delete telegram login allowed url: commit: %w", err) + } + return tag.RowsAffected() > 0, nil +} + +func (s *TelegramLoginStore) ListTelegramLoginAllowedURLs(ctx context.Context, botUserID int64) ([]domain.TelegramLoginAllowedURL, error) { + rows, err := s.db.Query(ctx, `SELECT id, bot_user_id, kind, normalized_url, created_at FROM bot_login_allowed_urls WHERE bot_user_id = $1 ORDER BY kind, id`, botUserID) + if err != nil { + return nil, fmt.Errorf("list telegram login allowed urls: %w", err) + } + defer rows.Close() + out := make([]domain.TelegramLoginAllowedURL, 0) + for rows.Next() { + allowed, err := scanTelegramLoginAllowedURL(rows) + if err != nil { + return nil, fmt.Errorf("scan telegram login allowed url: %w", err) + } + out = append(out, allowed) + } + return out, rows.Err() +} + +func (s *TelegramLoginStore) IsTelegramLoginURLAllowed(ctx context.Context, botUserID int64, kind domain.TelegramLoginAllowedURLKind, normalizedURL string) (bool, error) { + var allowed bool + err := s.db.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM bot_login_allowed_urls WHERE bot_user_id = $1 AND kind = $2 AND normalized_url = $3)`, botUserID, string(kind), normalizedURL).Scan(&allowed) + if err != nil { + return false, fmt.Errorf("check telegram login allowed url: %w", err) + } + return allowed, nil +} + +func scanTelegramLoginNativeApp(row telegramLoginRowScanner) (domain.TelegramLoginNativeApp, error) { + var app domain.TelegramLoginNativeApp + var platform string + if err := row.Scan(&app.ID, &app.BotUserID, &platform, &app.ApplicationID, &app.VerificationID, &app.CallbackURI, &app.VerifiedDisplayName, &app.Enabled, &app.CreatedAt, &app.UpdatedAt); err != nil { + return domain.TelegramLoginNativeApp{}, err + } + app.Platform = domain.TelegramLoginNativePlatform(platform) + return app, nil +} + +const telegramLoginNativeAppColumns = `id, bot_user_id, platform, application_id, verification_id, callback_uri, verified_display_name, enabled, created_at, updated_at` + +func (s *TelegramLoginStore) UpsertTelegramLoginNativeApp(ctx context.Context, app domain.TelegramLoginNativeApp) (domain.TelegramLoginNativeApp, error) { + if err := app.Validate(); err != nil { + return domain.TelegramLoginNativeApp{}, domain.ErrTelegramLoginClientInvalid + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.TelegramLoginNativeApp{}, fmt.Errorf("upsert telegram login native app: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.TelegramLoginNativeApp{}, fmt.Errorf("upsert telegram login native app: begin: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + var exists bool + if err := tx.QueryRow(ctx, `SELECT true FROM bot_login_clients WHERE bot_user_id = $1 FOR UPDATE`, app.BotUserID).Scan(&exists); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.TelegramLoginNativeApp{}, domain.ErrTelegramLoginClientInvalid + } + return domain.TelegramLoginNativeApp{}, fmt.Errorf("upsert telegram login native app: lock client: %w", err) + } + if app.ID == 0 { + var current int + if err := tx.QueryRow(ctx, `SELECT count(*) FROM bot_login_native_apps WHERE bot_user_id = $1`, app.BotUserID).Scan(¤t); err != nil { + return domain.TelegramLoginNativeApp{}, fmt.Errorf("upsert telegram login native app: capacity: %w", err) + } + if current >= domain.MaxTelegramLoginNativeApps { + // A duplicate configuration remains an idempotent update at capacity. + var duplicate bool + if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM bot_login_native_apps WHERE bot_user_id=$1 AND platform=$2 AND application_id=$3 AND verification_id=$4)`, app.BotUserID, string(app.Platform), app.ApplicationID, app.VerificationID).Scan(&duplicate); err != nil { + return domain.TelegramLoginNativeApp{}, fmt.Errorf("upsert telegram login native app: duplicate check: %w", err) + } + if !duplicate { + return domain.TelegramLoginNativeApp{}, domain.ErrTelegramLoginRequestInvalid + } + } + } + var row telegramLoginRowScanner + if app.ID == 0 { + row = tx.QueryRow(ctx, ` +INSERT INTO bot_login_native_apps ( + bot_user_id, platform, application_id, verification_id, callback_uri, + verified_display_name, enabled, created_at, updated_at +) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) +ON CONFLICT (bot_user_id, platform, application_id, verification_id) DO UPDATE SET + callback_uri = EXCLUDED.callback_uri, + verified_display_name = EXCLUDED.verified_display_name, + enabled = EXCLUDED.enabled, + updated_at = EXCLUDED.updated_at +RETURNING `+telegramLoginNativeAppColumns, + app.BotUserID, string(app.Platform), app.ApplicationID, app.VerificationID, + app.CallbackURI, app.VerifiedDisplayName, app.Enabled, app.CreatedAt, app.UpdatedAt) + } else { + row = tx.QueryRow(ctx, ` +UPDATE bot_login_native_apps SET + platform = $3, application_id = $4, verification_id = $5, + callback_uri = $6, verified_display_name = $7, enabled = $8, updated_at = $9 +WHERE id = $1 AND bot_user_id = $2 +RETURNING `+telegramLoginNativeAppColumns, + app.ID, app.BotUserID, string(app.Platform), app.ApplicationID, app.VerificationID, + app.CallbackURI, app.VerifiedDisplayName, app.Enabled, app.UpdatedAt) + } + out, err := scanTelegramLoginNativeApp(row) + if errors.Is(err, pgx.ErrNoRows) { + return domain.TelegramLoginNativeApp{}, domain.ErrTelegramLoginClientInvalid + } + if err != nil { + return domain.TelegramLoginNativeApp{}, mapTelegramLoginWriteError("upsert telegram login native app", err) + } + if err := tx.Commit(ctx); err != nil { + return domain.TelegramLoginNativeApp{}, fmt.Errorf("upsert telegram login native app: commit: %w", err) + } + return out, nil +} + +func (s *TelegramLoginStore) DeleteTelegramLoginNativeApp(ctx context.Context, botUserID, appID int64) (bool, error) { + beginner, ok := s.db.(txBeginner) + if !ok { + return false, fmt.Errorf("delete telegram login native app: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return false, fmt.Errorf("delete telegram login native app: begin: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + var exists bool + if err := tx.QueryRow(ctx, `SELECT true FROM bot_login_clients WHERE bot_user_id = $1 FOR UPDATE`, botUserID).Scan(&exists); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return false, domain.ErrTelegramLoginClientInvalid + } + return false, fmt.Errorf("delete telegram login native app: lock client: %w", err) + } + tag, err := tx.Exec(ctx, `DELETE FROM bot_login_native_apps WHERE id = $1 AND bot_user_id = $2`, appID, botUserID) + if err != nil { + return false, fmt.Errorf("delete telegram login native app: %w", err) + } + if err := tx.Commit(ctx); err != nil { + return false, fmt.Errorf("delete telegram login native app: commit: %w", err) + } + return tag.RowsAffected() > 0, nil +} + +func (s *TelegramLoginStore) ListTelegramLoginNativeApps(ctx context.Context, botUserID int64) ([]domain.TelegramLoginNativeApp, error) { + rows, err := s.db.Query(ctx, `SELECT `+telegramLoginNativeAppColumns+` FROM bot_login_native_apps WHERE bot_user_id = $1 ORDER BY id LIMIT $2`, botUserID, domain.MaxTelegramLoginNativeApps) + if err != nil { + return nil, fmt.Errorf("list telegram login native apps: %w", err) + } + defer rows.Close() + out := make([]domain.TelegramLoginNativeApp, 0) + for rows.Next() { + app, err := scanTelegramLoginNativeApp(rows) + if err != nil { + return nil, fmt.Errorf("scan telegram login native app: %w", err) + } + out = append(out, app) + } + return out, rows.Err() +} + +const telegramLoginRequestColumns = ` +id, request_token_hash, browser_token_hash, bot_user_id, client_id, +signing_algorithm, source, response_type, redirect_uri, origin, domain, +requested_scopes, oauth_state, nonce, code_challenge, code_challenge_method, +browser, platform, ip, region, in_app_origin, is_app, verified_app_name, +match_codes, match_code, match_codes_first, user_id_hint, +peer_type, peer_id, message_id, button_id, +status, authorized_user_id, profile_name, given_name, family_name, +preferred_username, picture, phone_number, write_allowed, phone_shared, +created_at, expires_at, approved_at, declined_at` + +func telegramLoginScopeStrings(scopes []domain.TelegramLoginScope) []string { + out := make([]string, len(scopes)) + for i, scope := range scopes { + out[i] = string(scope) + } + return out +} + +func telegramLoginScopes(values []string) []domain.TelegramLoginScope { + out := make([]domain.TelegramLoginScope, len(values)) + for i, value := range values { + out[i] = domain.TelegramLoginScope(value) + } + return out +} + +func scanTelegramLoginRequest(row telegramLoginRowScanner) (domain.TelegramLoginRequest, error) { + var request domain.TelegramLoginRequest + var algorithm, source, status, peerType string + var scopes []string + var authorizedUserID sql.NullInt64 + var approvedAt, declinedAt sql.NullTime + var messageID, buttonID int32 + if err := row.Scan( + &request.ID, &request.RequestTokenHash, &request.BrowserTokenHash, &request.BotUserID, &request.ClientID, + &algorithm, &source, &request.ResponseType, &request.RedirectURI, &request.Origin, &request.Domain, + &scopes, &request.State, &request.Nonce, &request.CodeChallenge, &request.CodeChallengeMethod, + &request.Browser, &request.Platform, &request.IP, &request.Region, &request.InAppOrigin, &request.IsApp, &request.VerifiedAppName, + &request.MatchCodes, &request.MatchCode, &request.MatchCodesFirst, &request.UserIDHint, + &peerType, &request.PeerID, &messageID, &buttonID, + &status, &authorizedUserID, &request.ProfileName, &request.GivenName, &request.FamilyName, + &request.PreferredUsername, &request.Picture, &request.PhoneNumber, &request.WriteAllowed, &request.PhoneShared, + &request.CreatedAt, &request.ExpiresAt, &approvedAt, &declinedAt, + ); err != nil { + return domain.TelegramLoginRequest{}, err + } + request.SigningAlgorithm = domain.TelegramLoginSigningAlgorithm(algorithm) + request.Source = domain.TelegramLoginRequestSource(source) + request.Scopes = telegramLoginScopes(scopes) + request.PeerType = domain.PeerType(peerType) + request.MessageID = int(messageID) + request.ButtonID = int(buttonID) + request.Status = domain.TelegramLoginRequestState(status) + if authorizedUserID.Valid { + request.AuthorizedUserID = authorizedUserID.Int64 + } + if approvedAt.Valid { + request.ApprovedAt = approvedAt.Time + } + if declinedAt.Valid { + request.DeclinedAt = declinedAt.Time + } + return request, nil +} + +func (s *TelegramLoginStore) CreateTelegramLoginRequest(ctx context.Context, request domain.TelegramLoginRequest) (domain.TelegramLoginRequest, error) { + if err := request.Validate(); err != nil { + return domain.TelegramLoginRequest{}, err + } + row := s.db.QueryRow(ctx, ` +INSERT INTO telegram_login_requests ( + request_token_hash, browser_token_hash, bot_user_id, client_id, + signing_algorithm, source, response_type, redirect_uri, origin, domain, + requested_scopes, oauth_state, nonce, code_challenge, code_challenge_method, + browser, platform, ip, region, in_app_origin, is_app, verified_app_name, + match_codes, match_code, match_codes_first, user_id_hint, + peer_type, peer_id, message_id, button_id, status, created_at, expires_at +) +SELECT + $1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20, + $21,$22,$23,$24,$25,$26,$27,$28,$29,$30,$31,$32,$33 +FROM bot_login_clients c +WHERE c.bot_user_id = $3 AND c.client_id = $4 AND c.enabled + AND c.signing_algorithm = $5 +RETURNING `+telegramLoginRequestColumns, + request.RequestTokenHash, request.BrowserTokenHash, request.BotUserID, request.ClientID, + string(request.SigningAlgorithm), string(request.Source), request.ResponseType, request.RedirectURI, request.Origin, request.Domain, + telegramLoginScopeStrings(request.Scopes), request.State, request.Nonce, request.CodeChallenge, request.CodeChallengeMethod, + request.Browser, request.Platform, request.IP, request.Region, request.InAppOrigin, request.IsApp, request.VerifiedAppName, + request.MatchCodes, request.MatchCode, request.MatchCodesFirst, request.UserIDHint, + string(request.PeerType), request.PeerID, request.MessageID, request.ButtonID, string(request.Status), request.CreatedAt, request.ExpiresAt) + out, err := scanTelegramLoginRequest(row) + if errors.Is(err, pgx.ErrNoRows) { + return domain.TelegramLoginRequest{}, domain.ErrTelegramLoginClientDisabled + } + if err != nil { + return domain.TelegramLoginRequest{}, mapTelegramLoginWriteError("create telegram login request", err) + } + return out, nil +} + +func (s *TelegramLoginStore) GetTelegramLoginRequest(ctx context.Context, requestID int64) (domain.TelegramLoginRequest, bool, error) { + request, err := scanTelegramLoginRequest(s.db.QueryRow(ctx, `SELECT `+telegramLoginRequestColumns+` FROM telegram_login_requests WHERE id = $1`, requestID)) + if errors.Is(err, pgx.ErrNoRows) { + return domain.TelegramLoginRequest{}, false, nil + } + if err != nil { + return domain.TelegramLoginRequest{}, false, fmt.Errorf("get telegram login request: %w", err) + } + return request, true, nil +} + +func (s *TelegramLoginStore) GetTelegramLoginRequestByTokenHash(ctx context.Context, tokenHash []byte) (domain.TelegramLoginRequest, bool, error) { + request, err := scanTelegramLoginRequest(s.db.QueryRow(ctx, `SELECT `+telegramLoginRequestColumns+` FROM telegram_login_requests WHERE request_token_hash = $1`, tokenHash)) + if errors.Is(err, pgx.ErrNoRows) { + return domain.TelegramLoginRequest{}, false, nil + } + if err != nil { + return domain.TelegramLoginRequest{}, false, fmt.Errorf("get telegram login request by token: %w", err) + } + return request, true, nil +} + +func (s *TelegramLoginStore) GetTelegramLoginRequestByBrowserTokenHash(ctx context.Context, tokenHash []byte) (domain.TelegramLoginRequest, bool, error) { + request, err := scanTelegramLoginRequest(s.db.QueryRow(ctx, `SELECT `+telegramLoginRequestColumns+` FROM telegram_login_requests WHERE browser_token_hash = $1`, tokenHash)) + if errors.Is(err, pgx.ErrNoRows) { + return domain.TelegramLoginRequest{}, false, nil + } + if err != nil { + return domain.TelegramLoginRequest{}, false, fmt.Errorf("get telegram login request by browser token: %w", err) + } + return request, true, nil +} + +func (s *TelegramLoginStore) ApproveTelegramLoginRequest(ctx context.Context, approval domain.TelegramLoginApproval, webAuthorizationHash int64) (domain.TelegramLoginRequest, domain.TelegramLoginWebAuthorization, error) { + if approval.RequestID <= 0 || approval.Identity.UserID <= 0 || webAuthorizationHash == 0 || approval.ApprovedAt.IsZero() { + return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginRequestInvalid + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, fmt.Errorf("approve telegram login request: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, fmt.Errorf("approve telegram login request: begin: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + request, err := scanTelegramLoginRequest(tx.QueryRow(ctx, `SELECT `+telegramLoginRequestColumns+` FROM telegram_login_requests WHERE id = $1 FOR UPDATE`, approval.RequestID)) + if errors.Is(err, pgx.ErrNoRows) { + return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginRequestInvalid + } + if err != nil { + return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, fmt.Errorf("approve telegram login request: lock: %w", err) + } + if request.Status != domain.TelegramLoginRequestPending { + return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginRequestConflict + } + if !approval.ApprovedAt.Before(request.ExpiresAt) { + if _, err := tx.Exec(ctx, `UPDATE telegram_login_requests SET status = 'expired' WHERE id = $1 AND status = 'pending'`, request.ID); err != nil { + return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, fmt.Errorf("approve telegram login request: mark expired: %w", err) + } + if err := tx.Commit(ctx); err != nil { + return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, fmt.Errorf("approve telegram login request: commit expiry: %w", err) + } + return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginRequestExpired + } + var clientEnabled, redirectAllowed, originAllowed bool + if err := tx.QueryRow(ctx, `SELECT enabled FROM bot_login_clients WHERE bot_user_id = $1 AND client_id = $2 AND signing_algorithm = $3 FOR UPDATE`, request.BotUserID, request.ClientID, string(request.SigningAlgorithm)).Scan(&clientEnabled); err != nil || !clientEnabled { + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, fmt.Errorf("approve telegram login request: client: %w", err) + } + return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginClientDisabled + } + redirectAllowed = request.ResponseType != "code" + if request.ResponseType == "code" { + if err := tx.QueryRow(ctx, `SELECT + EXISTS(SELECT 1 FROM bot_login_allowed_urls WHERE bot_user_id = $1 AND kind = 'redirect_uri' AND normalized_url = $2) + OR ($3 = 'native' AND EXISTS(SELECT 1 FROM bot_login_native_apps WHERE bot_user_id = $1 AND callback_uri = $2 AND enabled))`, request.BotUserID, request.RedirectURI, string(request.Source)).Scan(&redirectAllowed); err != nil { + return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, fmt.Errorf("approve telegram login request: redirect: %w", err) + } + if !redirectAllowed { + return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginRedirectNotAllowed + } + } else if request.ResponseType == "post_message" || request.ResponseType == "legacy_url" { + if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM bot_login_allowed_urls WHERE bot_user_id = $1 AND kind = 'web_origin' AND normalized_url = $2)`, request.BotUserID, request.Origin).Scan(&originAllowed); err != nil { + return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, fmt.Errorf("approve telegram login request: post-message origin: %w", err) + } + if !originAllowed { + return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginOriginNotAllowed + } + } else { + return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginRequestInvalid + } + originAllowed = request.InAppOrigin == "" + if !originAllowed { + if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM bot_login_allowed_urls WHERE bot_user_id = $1 AND kind = 'web_origin' AND normalized_url = $2)`, request.BotUserID, request.InAppOrigin).Scan(&originAllowed); err != nil { + return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, fmt.Errorf("approve telegram login request: origin: %w", err) + } + } + if !originAllowed { + return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginOriginNotAllowed + } + if len(request.MatchCodes) > 0 && approval.MatchCode != request.MatchCode { + return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginMatchCodeInvalid + } + scopes, err := grantedTelegramLoginScopesPG(request, approval) + if err != nil { + return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, err + } + identity, err := approval.Identity.Sanitized(request.Requests(domain.TelegramLoginScopeProfile), approval.PhoneShared) + if err != nil { + return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, err + } + // Serialize the per-user capacity check across requests and server + // instances. This prevents unbounded account.getWebAuthorizations payloads. + const telegramLoginAuthorizationLockNamespace int64 = 0x544c000000000000 + if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock($1)`, identity.UserID^telegramLoginAuthorizationLockNamespace); err != nil { + return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, fmt.Errorf("approve telegram login request: authorization capacity lock: %w", err) + } + var activeAuthorizations int + if err := tx.QueryRow(ctx, `SELECT count(*) FROM web_authorizations WHERE user_id = $1 AND revoked_at IS NULL`, identity.UserID).Scan(&activeAuthorizations); err != nil { + return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, fmt.Errorf("approve telegram login request: authorization capacity: %w", err) + } + if activeAuthorizations >= domain.MaxTelegramLoginWebAuthorizations { + return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginAuthorizationsTooMany + } + if approval.WriteAllowed { + if _, err := tx.Exec(ctx, ` +INSERT INTO bot_user_permissions (bot_user_id, user_id, from_request) +VALUES ($1,$2,true) +ON CONFLICT (bot_user_id, user_id) DO UPDATE SET + from_request = true, updated_at = now()`, request.BotUserID, identity.UserID); err != nil { + return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, fmt.Errorf("approve telegram login request: grant bot access: %w", err) + } + } + if _, err := tx.Exec(ctx, ` +UPDATE telegram_login_requests SET + status = 'approved', authorized_user_id = $2, write_allowed = $3, + phone_shared = $4, approved_at = $5, profile_name = $6, given_name = $7, + family_name = $8, preferred_username = $9, picture = $10, phone_number = $11 +WHERE id = $1`, request.ID, identity.UserID, approval.WriteAllowed, approval.PhoneShared, approval.ApprovedAt, + identity.Name, identity.GivenName, identity.FamilyName, identity.PreferredUsername, identity.Picture, identity.PhoneNumber); err != nil { + return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, fmt.Errorf("approve telegram login request: update request: %w", err) + } + web := domain.TelegramLoginWebAuthorization{ + Hash: webAuthorizationHash, RequestID: request.ID, UserID: identity.UserID, BotUserID: request.BotUserID, + Domain: request.Domain, Browser: request.Browser, Platform: request.Platform, IP: request.IP, Region: request.Region, + Scopes: scopes, PhoneShared: approval.PhoneShared, BotAccessGranted: approval.WriteAllowed, + CreatedAt: approval.ApprovedAt, LastActiveAt: approval.ApprovedAt, + } + if _, err := tx.Exec(ctx, ` +INSERT INTO web_authorizations ( + hash, request_id, user_id, bot_user_id, domain, browser, platform, ip, region, + granted_scopes, phone_shared, bot_access_granted, created_at, last_active_at +) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)`, + web.Hash, web.RequestID, web.UserID, web.BotUserID, web.Domain, web.Browser, web.Platform, web.IP, web.Region, + telegramLoginScopeStrings(web.Scopes), web.PhoneShared, web.BotAccessGranted, web.CreatedAt, web.LastActiveAt); err != nil { + return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, mapTelegramLoginWriteError("approve telegram login request: insert web authorization", err) + } + if err := tx.Commit(ctx); err != nil { + return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, fmt.Errorf("approve telegram login request: commit: %w", err) + } + request.Status = domain.TelegramLoginRequestApproved + request.AuthorizedUserID = identity.UserID + request.ProfileName = identity.Name + request.GivenName = identity.GivenName + request.FamilyName = identity.FamilyName + request.PreferredUsername = identity.PreferredUsername + request.Picture = identity.Picture + request.PhoneNumber = identity.PhoneNumber + request.WriteAllowed = approval.WriteAllowed + request.PhoneShared = approval.PhoneShared + request.ApprovedAt = approval.ApprovedAt + return request, web, nil +} + +func grantedTelegramLoginScopesPG(request domain.TelegramLoginRequest, approval domain.TelegramLoginApproval) ([]domain.TelegramLoginScope, error) { + if approval.WriteAllowed && !request.Requests(domain.TelegramLoginScopeBotAccess) { + return nil, domain.ErrTelegramLoginScopeInvalid + } + if approval.PhoneShared && !request.Requests(domain.TelegramLoginScopePhone) { + return nil, domain.ErrTelegramLoginScopeInvalid + } + out := make([]domain.TelegramLoginScope, 0, len(request.Scopes)) + for _, scope := range request.Scopes { + if scope == domain.TelegramLoginScopePhone && !approval.PhoneShared { + continue + } + if scope == domain.TelegramLoginScopeBotAccess && !approval.WriteAllowed { + continue + } + out = append(out, scope) + } + return out, nil +} + +func (s *TelegramLoginStore) DeclineTelegramLoginRequest(ctx context.Context, requestID, userID int64, now time.Time) (domain.TelegramLoginRequest, error) { + if requestID <= 0 || userID <= 0 || now.IsZero() { + return domain.TelegramLoginRequest{}, domain.ErrTelegramLoginRequestInvalid + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.TelegramLoginRequest{}, fmt.Errorf("decline telegram login request: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.TelegramLoginRequest{}, fmt.Errorf("decline telegram login request: begin: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + request, err := scanTelegramLoginRequest(tx.QueryRow(ctx, `SELECT `+telegramLoginRequestColumns+` FROM telegram_login_requests WHERE id = $1 FOR UPDATE`, requestID)) + if errors.Is(err, pgx.ErrNoRows) { + return domain.TelegramLoginRequest{}, domain.ErrTelegramLoginRequestInvalid + } + if err != nil { + return domain.TelegramLoginRequest{}, fmt.Errorf("decline telegram login request: lock: %w", err) + } + if request.Status != domain.TelegramLoginRequestPending { + return domain.TelegramLoginRequest{}, domain.ErrTelegramLoginRequestConflict + } + if !now.Before(request.ExpiresAt) { + if _, err := tx.Exec(ctx, `UPDATE telegram_login_requests SET status = 'expired' WHERE id = $1`, request.ID); err != nil { + return domain.TelegramLoginRequest{}, fmt.Errorf("decline telegram login request: mark expired: %w", err) + } + if err := tx.Commit(ctx); err != nil { + return domain.TelegramLoginRequest{}, fmt.Errorf("decline telegram login request: commit expiry: %w", err) + } + return domain.TelegramLoginRequest{}, domain.ErrTelegramLoginRequestExpired + } + if _, err := tx.Exec(ctx, `UPDATE telegram_login_requests SET status = 'declined', declined_at = $2 WHERE id = $1`, request.ID, now); err != nil { + return domain.TelegramLoginRequest{}, fmt.Errorf("decline telegram login request: update: %w", err) + } + if err := tx.Commit(ctx); err != nil { + return domain.TelegramLoginRequest{}, fmt.Errorf("decline telegram login request: commit: %w", err) + } + request.Status = domain.TelegramLoginRequestDeclined + request.DeclinedAt = now + return request, nil +} + +const telegramLoginCodeColumns = `id, request_id, code_hash, sealed_code, seal_nonce, seal_key_id, issued_at, expires_at, consumed_at` + +func scanTelegramLoginAuthorizationCode(row telegramLoginRowScanner) (domain.TelegramLoginAuthorizationCode, error) { + var code domain.TelegramLoginAuthorizationCode + var consumedAt sql.NullTime + if err := row.Scan(&code.ID, &code.RequestID, &code.CodeHash, &code.SealedCode, &code.SealNonce, &code.SealKeyID, &code.IssuedAt, &code.ExpiresAt, &consumedAt); err != nil { + return domain.TelegramLoginAuthorizationCode{}, err + } + if consumedAt.Valid { + code.ConsumedAt = consumedAt.Time + } + return code, nil +} + +func (s *TelegramLoginStore) PutTelegramLoginAuthorizationCode(ctx context.Context, code domain.TelegramLoginAuthorizationCode) (domain.TelegramLoginAuthorizationCode, error) { + if code.RequestID <= 0 || len(code.CodeHash) != 32 || len(code.SealedCode) < 32 || len(code.SealNonce) < 12 || code.SealKeyID == "" || code.IssuedAt.IsZero() || !code.ExpiresAt.After(code.IssuedAt) { + return domain.TelegramLoginAuthorizationCode{}, domain.ErrTelegramLoginCodeInvalid + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.TelegramLoginAuthorizationCode{}, fmt.Errorf("put telegram login code: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.TelegramLoginAuthorizationCode{}, fmt.Errorf("put telegram login code: begin: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + request, err := scanTelegramLoginRequest(tx.QueryRow(ctx, `SELECT `+telegramLoginRequestColumns+` FROM telegram_login_requests WHERE id = $1 FOR UPDATE`, code.RequestID)) + if errors.Is(err, pgx.ErrNoRows) { + return domain.TelegramLoginAuthorizationCode{}, domain.ErrTelegramLoginRequestInvalid + } else if err != nil { + return domain.TelegramLoginAuthorizationCode{}, fmt.Errorf("put telegram login code: lock request: %w", err) + } + if request.Status != domain.TelegramLoginRequestApproved { + return domain.TelegramLoginAuthorizationCode{}, domain.ErrTelegramLoginRequestConflict + } + var clientEnabled bool + if err := tx.QueryRow(ctx, `SELECT enabled FROM bot_login_clients WHERE bot_user_id = $1 AND client_id = $2 AND signing_algorithm = $3 FOR UPDATE`, + request.BotUserID, request.ClientID, string(request.SigningAlgorithm)).Scan(&clientEnabled); err != nil || !clientEnabled { + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return domain.TelegramLoginAuthorizationCode{}, fmt.Errorf("put telegram login code: client: %w", err) + } + return domain.TelegramLoginAuthorizationCode{}, domain.ErrTelegramLoginClientDisabled + } + switch request.ResponseType { + case "code": + var allowed bool + if err := tx.QueryRow(ctx, `SELECT + EXISTS(SELECT 1 FROM bot_login_allowed_urls WHERE bot_user_id = $1 AND kind = 'redirect_uri' AND normalized_url = $2) + OR ($3 = 'native' AND EXISTS(SELECT 1 FROM bot_login_native_apps WHERE bot_user_id = $1 AND callback_uri = $2 AND enabled))`, + request.BotUserID, request.RedirectURI, string(request.Source)).Scan(&allowed); err != nil { + return domain.TelegramLoginAuthorizationCode{}, fmt.Errorf("put telegram login code: redirect: %w", err) + } + if !allowed { + return domain.TelegramLoginAuthorizationCode{}, domain.ErrTelegramLoginRedirectNotAllowed + } + case "post_message": + var allowed bool + if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM bot_login_allowed_urls WHERE bot_user_id = $1 AND kind = 'web_origin' AND normalized_url = $2)`, + request.BotUserID, request.Origin).Scan(&allowed); err != nil { + return domain.TelegramLoginAuthorizationCode{}, fmt.Errorf("put telegram login code: origin: %w", err) + } + if !allowed { + return domain.TelegramLoginAuthorizationCode{}, domain.ErrTelegramLoginOriginNotAllowed + } + default: + return domain.TelegramLoginAuthorizationCode{}, domain.ErrTelegramLoginRequestConflict + } + web, err := scanTelegramLoginWebAuthorization(tx.QueryRow(ctx, `SELECT `+telegramLoginWebAuthorizationColumns+` FROM web_authorizations WHERE request_id = $1 FOR UPDATE`, code.RequestID)) + if errors.Is(err, pgx.ErrNoRows) || !web.RevokedAt.IsZero() { + return domain.TelegramLoginAuthorizationCode{}, domain.ErrTelegramLoginRequestConflict + } + if err != nil { + return domain.TelegramLoginAuthorizationCode{}, fmt.Errorf("put telegram login code: web authorization: %w", err) + } + existing, err := scanTelegramLoginAuthorizationCode(tx.QueryRow(ctx, `SELECT `+telegramLoginCodeColumns+` FROM telegram_login_codes WHERE request_id = $1`, code.RequestID)) + if err == nil { + if err := tx.Commit(ctx); err != nil { + return domain.TelegramLoginAuthorizationCode{}, fmt.Errorf("put telegram login code: commit existing: %w", err) + } + return existing, nil + } + if !errors.Is(err, pgx.ErrNoRows) { + return domain.TelegramLoginAuthorizationCode{}, fmt.Errorf("put telegram login code: read existing: %w", err) + } + created, err := scanTelegramLoginAuthorizationCode(tx.QueryRow(ctx, ` +INSERT INTO telegram_login_codes ( + request_id, code_hash, sealed_code, seal_nonce, seal_key_id, issued_at, expires_at +) VALUES ($1,$2,$3,$4,$5,$6,$7) +RETURNING `+telegramLoginCodeColumns, + code.RequestID, code.CodeHash, code.SealedCode, code.SealNonce, code.SealKeyID, code.IssuedAt, code.ExpiresAt)) + if err != nil { + return domain.TelegramLoginAuthorizationCode{}, mapTelegramLoginWriteError("put telegram login code", err) + } + if err := tx.Commit(ctx); err != nil { + return domain.TelegramLoginAuthorizationCode{}, fmt.Errorf("put telegram login code: commit: %w", err) + } + return created, nil +} + +func (s *TelegramLoginStore) GetTelegramLoginAuthorizationCodeByRequest(ctx context.Context, requestID int64) (domain.TelegramLoginAuthorizationCode, bool, error) { + code, err := scanTelegramLoginAuthorizationCode(s.db.QueryRow(ctx, `SELECT `+telegramLoginCodeColumns+` FROM telegram_login_codes WHERE request_id = $1`, requestID)) + if errors.Is(err, pgx.ErrNoRows) { + return domain.TelegramLoginAuthorizationCode{}, false, nil + } + if err != nil { + return domain.TelegramLoginAuthorizationCode{}, false, fmt.Errorf("get telegram login code by request: %w", err) + } + return code, true, nil +} + +func (s *TelegramLoginStore) GetTelegramLoginAuthorizationCodeByHash(ctx context.Context, codeHash []byte) (domain.TelegramLoginAuthorizationCode, bool, error) { + code, err := scanTelegramLoginAuthorizationCode(s.db.QueryRow(ctx, `SELECT `+telegramLoginCodeColumns+` FROM telegram_login_codes WHERE code_hash = $1`, codeHash)) + if errors.Is(err, pgx.ErrNoRows) { + return domain.TelegramLoginAuthorizationCode{}, false, nil + } + if err != nil { + return domain.TelegramLoginAuthorizationCode{}, false, fmt.Errorf("get telegram login code by hash: %w", err) + } + return code, true, nil +} + +const telegramLoginWebAuthorizationColumns = `hash, request_id, user_id, bot_user_id, domain, browser, platform, ip, region, granted_scopes, phone_shared, bot_access_granted, created_at, last_active_at, revoked_at` + +func scanTelegramLoginWebAuthorization(row telegramLoginRowScanner) (domain.TelegramLoginWebAuthorization, error) { + var web domain.TelegramLoginWebAuthorization + var scopes []string + var revokedAt sql.NullTime + if err := row.Scan(&web.Hash, &web.RequestID, &web.UserID, &web.BotUserID, &web.Domain, &web.Browser, &web.Platform, &web.IP, &web.Region, &scopes, &web.PhoneShared, &web.BotAccessGranted, &web.CreatedAt, &web.LastActiveAt, &revokedAt); err != nil { + return domain.TelegramLoginWebAuthorization{}, err + } + web.Scopes = telegramLoginScopes(scopes) + if revokedAt.Valid { + web.RevokedAt = revokedAt.Time + } + return web, nil +} + +func (s *TelegramLoginStore) ConsumeTelegramLoginAuthorizationCode(ctx context.Context, exchange domain.TelegramLoginCodeExchange) (domain.TelegramLoginAuthorizationCode, domain.TelegramLoginRequest, domain.TelegramLoginWebAuthorization, error) { + if len(exchange.CodeHash) != 32 || exchange.ClientID == "" || exchange.ClientSecretVersion <= 0 || exchange.RedirectURI == "" || exchange.CodeChallenge == "" || exchange.Now.IsZero() { + return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeInvalid + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, fmt.Errorf("consume telegram login code: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, fmt.Errorf("consume telegram login code: begin: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + code, err := scanTelegramLoginAuthorizationCode(tx.QueryRow(ctx, `SELECT `+telegramLoginCodeColumns+` FROM telegram_login_codes WHERE code_hash = $1 FOR UPDATE`, exchange.CodeHash)) + if errors.Is(err, pgx.ErrNoRows) { + return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeInvalid + } + if err != nil { + return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, fmt.Errorf("consume telegram login code: lock code: %w", err) + } + if !code.ConsumedAt.IsZero() { + return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeConsumed + } + if !exchange.Now.Before(code.ExpiresAt) { + return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeInvalid + } + request, err := scanTelegramLoginRequest(tx.QueryRow(ctx, `SELECT `+telegramLoginRequestColumns+` FROM telegram_login_requests WHERE id = $1 FOR UPDATE`, code.RequestID)) + if err != nil { + return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, fmt.Errorf("consume telegram login code: lock request: %w", err) + } + if request.Status != domain.TelegramLoginRequestApproved || request.ResponseType != "code" || request.ClientID != exchange.ClientID || request.RedirectURI != exchange.RedirectURI || request.CodeChallenge != exchange.CodeChallenge { + return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeInvalid + } + var clientEnabled, redirectAllowed bool + var secretVersion int64 + if err := tx.QueryRow(ctx, `SELECT enabled, secret_version FROM bot_login_clients WHERE client_id = $1 FOR UPDATE`, exchange.ClientID).Scan(&clientEnabled, &secretVersion); err != nil || !clientEnabled || secretVersion != exchange.ClientSecretVersion { + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, fmt.Errorf("consume telegram login code: client: %w", err) + } + return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeInvalid + } + if err := tx.QueryRow(ctx, `SELECT + EXISTS(SELECT 1 FROM bot_login_allowed_urls WHERE bot_user_id = $1 AND kind = 'redirect_uri' AND normalized_url = $2) + OR ($3 = 'native' AND EXISTS(SELECT 1 FROM bot_login_native_apps WHERE bot_user_id = $1 AND callback_uri = $2 AND enabled))`, request.BotUserID, exchange.RedirectURI, string(request.Source)).Scan(&redirectAllowed); err != nil { + return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, fmt.Errorf("consume telegram login code: redirect: %w", err) + } + if !redirectAllowed { + return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeInvalid + } + web, err := scanTelegramLoginWebAuthorization(tx.QueryRow(ctx, `SELECT `+telegramLoginWebAuthorizationColumns+` FROM web_authorizations WHERE request_id = $1 FOR UPDATE`, request.ID)) + if err != nil || !web.RevokedAt.IsZero() { + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, fmt.Errorf("consume telegram login code: web authorization: %w", err) + } + return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeInvalid + } + if _, err := tx.Exec(ctx, `UPDATE telegram_login_codes SET consumed_at = $2 WHERE id = $1`, code.ID, exchange.Now); err != nil { + return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, fmt.Errorf("consume telegram login code: mark consumed: %w", err) + } + if _, err := tx.Exec(ctx, `UPDATE web_authorizations SET last_active_at = $2 WHERE hash = $1`, web.Hash, exchange.Now); err != nil { + return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, fmt.Errorf("consume telegram login code: touch web authorization: %w", err) + } + if err := tx.Commit(ctx); err != nil { + return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, fmt.Errorf("consume telegram login code: commit: %w", err) + } + code.ConsumedAt = exchange.Now + web.LastActiveAt = exchange.Now + return code, request, web, nil +} + +func (s *TelegramLoginStore) ConsumeTelegramLoginDirectToken(ctx context.Context, tokenHash []byte, origin string, now time.Time) (domain.TelegramLoginAuthorizationCode, domain.TelegramLoginRequest, domain.TelegramLoginWebAuthorization, error) { + if len(tokenHash) != 32 || origin == "" || now.IsZero() { + return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeInvalid + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, fmt.Errorf("consume telegram login direct token: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, fmt.Errorf("consume telegram login direct token: begin: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + code, err := scanTelegramLoginAuthorizationCode(tx.QueryRow(ctx, `SELECT `+telegramLoginCodeColumns+` FROM telegram_login_codes WHERE code_hash = $1 FOR UPDATE`, tokenHash)) + if errors.Is(err, pgx.ErrNoRows) { + return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeInvalid + } + if err != nil { + return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, fmt.Errorf("consume telegram login direct token: lock token: %w", err) + } + if !code.ConsumedAt.IsZero() { + return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeConsumed + } + if !now.Before(code.ExpiresAt) { + return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeInvalid + } + request, err := scanTelegramLoginRequest(tx.QueryRow(ctx, `SELECT `+telegramLoginRequestColumns+` FROM telegram_login_requests WHERE id = $1 FOR UPDATE`, code.RequestID)) + if err != nil { + return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, fmt.Errorf("consume telegram login direct token: lock request: %w", err) + } + if request.Status != domain.TelegramLoginRequestApproved || request.Source != domain.TelegramLoginRequestMiniApp || + request.ResponseType != "post_message" || request.Origin != origin || request.InAppOrigin != origin { + return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeInvalid + } + var clientEnabled, originAllowed bool + if err := tx.QueryRow(ctx, `SELECT enabled FROM bot_login_clients WHERE bot_user_id = $1 AND client_id = $2 FOR UPDATE`, request.BotUserID, request.ClientID).Scan(&clientEnabled); err != nil || !clientEnabled { + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, fmt.Errorf("consume telegram login direct token: client: %w", err) + } + return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeInvalid + } + if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM bot_login_allowed_urls WHERE bot_user_id = $1 AND kind = 'web_origin' AND normalized_url = $2)`, request.BotUserID, origin).Scan(&originAllowed); err != nil { + return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, fmt.Errorf("consume telegram login direct token: origin: %w", err) + } + if !originAllowed { + return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeInvalid + } + web, err := scanTelegramLoginWebAuthorization(tx.QueryRow(ctx, `SELECT `+telegramLoginWebAuthorizationColumns+` FROM web_authorizations WHERE request_id = $1 FOR UPDATE`, request.ID)) + if err != nil || !web.RevokedAt.IsZero() { + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, fmt.Errorf("consume telegram login direct token: web authorization: %w", err) + } + return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeInvalid + } + if _, err := tx.Exec(ctx, `UPDATE telegram_login_codes SET consumed_at = $2 WHERE id = $1`, code.ID, now); err != nil { + return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, fmt.Errorf("consume telegram login direct token: mark consumed: %w", err) + } + if _, err := tx.Exec(ctx, `UPDATE web_authorizations SET last_active_at = $2 WHERE hash = $1`, web.Hash, now); err != nil { + return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, fmt.Errorf("consume telegram login direct token: touch web authorization: %w", err) + } + if err := tx.Commit(ctx); err != nil { + return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, fmt.Errorf("consume telegram login direct token: commit: %w", err) + } + code.ConsumedAt = now + web.LastActiveAt = now + return code, request, web, nil +} + +func (s *TelegramLoginStore) ListTelegramLoginWebAuthorizations(ctx context.Context, userID int64) ([]domain.TelegramLoginWebAuthorization, error) { + rows, err := s.db.Query(ctx, `SELECT `+telegramLoginWebAuthorizationColumns+` FROM web_authorizations WHERE user_id = $1 AND revoked_at IS NULL ORDER BY last_active_at DESC, hash DESC LIMIT $2`, userID, domain.MaxTelegramLoginWebAuthorizations) + if err != nil { + return nil, fmt.Errorf("list telegram login web authorizations: %w", err) + } + defer rows.Close() + out := make([]domain.TelegramLoginWebAuthorization, 0) + for rows.Next() { + web, err := scanTelegramLoginWebAuthorization(rows) + if err != nil { + return nil, fmt.Errorf("scan telegram login web authorization: %w", err) + } + out = append(out, web) + } + return out, rows.Err() +} + +func (s *TelegramLoginStore) RevokeTelegramLoginWebAuthorization(ctx context.Context, userID, hash int64, now time.Time) (bool, error) { + tag, err := s.db.Exec(ctx, `UPDATE web_authorizations SET revoked_at = $3 WHERE user_id = $1 AND hash = $2 AND revoked_at IS NULL`, userID, hash, now) + if err != nil { + return false, fmt.Errorf("revoke telegram login web authorization: %w", err) + } + return tag.RowsAffected() > 0, nil +} + +func (s *TelegramLoginStore) RevokeAllTelegramLoginWebAuthorizations(ctx context.Context, userID int64, now time.Time) (int64, error) { + tag, err := s.db.Exec(ctx, `UPDATE web_authorizations SET revoked_at = $2 WHERE user_id = $1 AND revoked_at IS NULL`, userID, now) + if err != nil { + return 0, fmt.Errorf("revoke all telegram login web authorizations: %w", err) + } + return tag.RowsAffected(), nil +} + +func (s *TelegramLoginStore) DeleteExpiredTelegramLoginArtifacts(ctx context.Context, before time.Time, limit int) (int64, error) { + if limit <= 0 || limit > 1000 { + return 0, domain.ErrTelegramLoginRequestInvalid + } + beginner, ok := s.db.(txBeginner) + if !ok { + return 0, fmt.Errorf("delete expired telegram login artifacts: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return 0, fmt.Errorf("delete expired telegram login artifacts: begin: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + var deletedCodes int64 + if err := tx.QueryRow(ctx, ` +WITH doomed AS ( + SELECT id FROM telegram_login_codes + WHERE expires_at < $1 OR (consumed_at IS NOT NULL AND consumed_at < $1) + ORDER BY expires_at, id + LIMIT $2 + FOR UPDATE SKIP LOCKED +), deleted AS ( + DELETE FROM telegram_login_codes c USING doomed d WHERE c.id = d.id RETURNING c.id +) +SELECT count(*) FROM deleted`, before, limit).Scan(&deletedCodes); err != nil { + return 0, fmt.Errorf("delete expired telegram login codes: %w", err) + } + remaining := int64(limit) - deletedCodes + var deletedRequests int64 + if remaining > 0 { + if err := tx.QueryRow(ctx, ` +WITH doomed AS ( + SELECT r.id FROM telegram_login_requests r + WHERE (r.status IN ('pending','declined','expired') AND r.expires_at < $1) + OR (r.status = 'approved' AND r.approved_at < $1 + AND EXISTS ( + SELECT 1 FROM web_authorizations w + WHERE w.request_id = r.id AND w.revoked_at < $1 + ) + AND NOT EXISTS ( + SELECT 1 FROM telegram_login_codes c WHERE c.request_id = r.id + )) + ORDER BY COALESCE(r.approved_at, r.expires_at), r.id + LIMIT $2 + FOR UPDATE OF r SKIP LOCKED +), deleted AS ( + DELETE FROM telegram_login_requests r USING doomed d WHERE r.id = d.id RETURNING r.id +) +SELECT count(*) FROM deleted`, before, remaining).Scan(&deletedRequests); err != nil { + return 0, fmt.Errorf("delete expired telegram login requests: %w", err) + } + } + if err := tx.Commit(ctx); err != nil { + return 0, fmt.Errorf("delete expired telegram login artifacts: commit: %w", err) + } + return deletedCodes + deletedRequests, nil +} + +func mapTelegramLoginWriteError(op string, err error) error { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) { + switch pgErr.Code { + case pgerrcode.UniqueViolation: + return fmt.Errorf("%s: %w", op, domain.ErrTelegramLoginRequestConflict) + case pgerrcode.ForeignKeyViolation, pgerrcode.CheckViolation: + return fmt.Errorf("%s: %w", op, domain.ErrTelegramLoginRequestInvalid) + } + } + return fmt.Errorf("%s: %w", op, err) +} diff --git a/internal/store/postgres/telegram_login_integration_test.go b/internal/store/postgres/telegram_login_integration_test.go new file mode 100644 index 00000000..8a15b51b --- /dev/null +++ b/internal/store/postgres/telegram_login_integration_test.go @@ -0,0 +1,434 @@ +package postgres + +import ( + "context" + "crypto/sha256" + "errors" + "fmt" + "strings" + "sync" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "telesrv/internal/domain" +) + +func telegramLoginPGHash(value string) []byte { + sum := sha256.Sum256([]byte(value)) + return sum[:] +} + +func TestTelegramLoginStorePostgresAtomicStateAndCodeConsumption(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + now := time.Now().UTC().Truncate(time.Microsecond) + suffix := now.UnixNano() % 1_000_000_000 + + users := NewUserStore(pool) + bots := NewBotStore(pool) + owner, err := users.Create(ctx, domain.User{ + AccessHash: suffix + 101, + Phone: fmt.Sprintf("1777%09d", suffix), + FirstName: "OIDC Owner", + }) + if err != nil { + t.Fatalf("create oidc owner: %v", err) + } + bot, _, err := bots.CreateBotAccount(ctx, domain.User{ + AccessHash: suffix + 102, + FirstName: "OIDC Test Bot", + Username: fmt.Sprintf("oidc_%09d_bot", suffix), + }, domain.BotProfile{OwnerUserID: owner.ID, TokenSecret: "bot-secret"}) + if err != nil { + t.Fatalf("create oidc bot: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id IN ($1,$2)", owner.ID, bot.ID) + }) + + store := NewTelegramLoginStore(pool) + client, err := store.UpsertTelegramLoginClient(ctx, domain.TelegramLoginClient{ + BotUserID: bot.ID, + ClientID: fmt.Sprintf("%d", bot.ID), + SecretHash: telegramLoginPGHash("client-secret"), + SecretVersion: 1, + SigningAlgorithm: domain.TelegramLoginSigningRS256, + Enabled: true, + CreatedAt: now, + UpdatedAt: now, + }) + if err != nil { + t.Fatalf("upsert oidc client: %v", err) + } + redirectURI := fmt.Sprintf("https://rp-%d.example/callback", suffix) + origin := fmt.Sprintf("https://rp-%d.example", suffix) + if _, err := store.AddTelegramLoginAllowedURL(ctx, domain.TelegramLoginAllowedURL{ + BotUserID: client.BotUserID, Kind: domain.TelegramLoginAllowedRedirectURI, + NormalizedURL: redirectURI, CreatedAt: now, + }); err != nil { + t.Fatalf("add redirect: %v", err) + } + if _, err := store.AddTelegramLoginAllowedURL(ctx, domain.TelegramLoginAllowedURL{ + BotUserID: client.BotUserID, Kind: domain.TelegramLoginAllowedWebOrigin, + NormalizedURL: origin, CreatedAt: now, + }); err != nil { + t.Fatalf("add web origin: %v", err) + } + + newRequest := func(label string) domain.TelegramLoginRequest { + request, err := store.CreateTelegramLoginRequest(ctx, domain.TelegramLoginRequest{ + RequestTokenHash: telegramLoginPGHash("request-" + label), + BrowserTokenHash: telegramLoginPGHash("browser-" + label), + BotUserID: bot.ID, + ClientID: client.ClientID, + SigningAlgorithm: client.SigningAlgorithm, + Source: domain.TelegramLoginRequestWeb, + ResponseType: "code", + RedirectURI: redirectURI, + Origin: origin, + Domain: fmt.Sprintf("rp-%d.example", suffix), + Scopes: []domain.TelegramLoginScope{domain.TelegramLoginScopeOpenID, domain.TelegramLoginScopeProfile, domain.TelegramLoginScopeBotAccess}, + State: "state", + Nonce: "nonce", + CodeChallenge: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + CodeChallengeMethod: "S256", + Browser: "Firefox", + Platform: "Windows", + IP: "192.0.2.10", + Region: "Test Region", + MatchCodes: []string{"🟢", "🔵", "🟠"}, + MatchCode: "🔵", + MatchCodesFirst: true, + Status: domain.TelegramLoginRequestPending, + CreatedAt: now, + ExpiresAt: now.Add(5 * time.Minute), + }) + if err != nil { + t.Fatalf("create request %s: %v", label, err) + } + return request + } + + request := newRequest(fmt.Sprintf("race-%d", suffix)) + start := make(chan struct{}) + errs := make(chan error, 2) + go func() { + <-start + _, _, err := store.ApproveTelegramLoginRequest(ctx, domain.TelegramLoginApproval{ + RequestID: request.ID, + Identity: domain.TelegramLoginIdentitySnapshot{UserID: owner.ID, Name: owner.FirstName, GivenName: owner.FirstName}, + WriteAllowed: true, + MatchCode: request.MatchCode, ApprovedAt: now.Add(time.Second), + }, suffix+10_000) + errs <- err + }() + go func() { + <-start + _, err := store.DeclineTelegramLoginRequest(ctx, request.ID, owner.ID, now.Add(time.Second)) + errs <- err + }() + close(start) + var success, conflict int + for range 2 { + err := <-errs + switch { + case err == nil: + success++ + case errors.Is(err, domain.ErrTelegramLoginRequestConflict): + conflict++ + default: + t.Fatalf("accept/decline race error: %v", err) + } + } + if success != 1 || conflict != 1 { + t.Fatalf("accept/decline success=%d conflict=%d, want 1/1", success, conflict) + } + + codeRequest := newRequest(fmt.Sprintf("code-%d", suffix)) + _, web, err := store.ApproveTelegramLoginRequest(ctx, domain.TelegramLoginApproval{ + RequestID: codeRequest.ID, + Identity: domain.TelegramLoginIdentitySnapshot{UserID: owner.ID, Name: owner.FirstName, GivenName: owner.FirstName}, + WriteAllowed: true, + MatchCode: codeRequest.MatchCode, ApprovedAt: now.Add(2 * time.Second), + }, suffix+20_000) + if err != nil { + t.Fatalf("approve code request: %v", err) + } + canSend, err := bots.CanBotSendMessage(ctx, bot.ID, owner.ID) + if err != nil || !canSend { + t.Fatalf("bot access after atomic approval = %v,%v", canSend, err) + } + code := domain.TelegramLoginAuthorizationCode{ + RequestID: codeRequest.ID, + CodeHash: telegramLoginPGHash(fmt.Sprintf("code-%d", suffix)), + SealedCode: append(make([]byte, 32), 1), + SealNonce: make([]byte, 12), + SealKeyID: "integration-key", + IssuedAt: now.Add(3 * time.Second), + ExpiresAt: now.Add(time.Minute), + } + if _, err := store.PutTelegramLoginAuthorizationCode(ctx, code); err != nil { + t.Fatalf("put code: %v", err) + } + exchange := domain.TelegramLoginCodeExchange{ + CodeHash: code.CodeHash, ClientID: client.ClientID, ClientSecretVersion: client.SecretVersion, + RedirectURI: codeRequest.RedirectURI, CodeChallenge: codeRequest.CodeChallenge, Now: now.Add(4 * time.Second), + } + start = make(chan struct{}) + errs = make(chan error, 8) + var wg sync.WaitGroup + for range 8 { + wg.Add(1) + go func() { + defer wg.Done() + <-start + _, _, _, err := store.ConsumeTelegramLoginAuthorizationCode(ctx, exchange) + errs <- err + }() + } + close(start) + wg.Wait() + close(errs) + success, conflict = 0, 0 + for err := range errs { + switch { + case err == nil: + success++ + case errors.Is(err, domain.ErrTelegramLoginCodeConsumed): + conflict++ + default: + t.Fatalf("code consume race error: %v", err) + } + } + if success != 1 || conflict != 7 { + t.Fatalf("code consume success=%d consumed=%d, want 1/7", success, conflict) + } + + miniRequest, err := store.CreateTelegramLoginRequest(ctx, domain.TelegramLoginRequest{ + RequestTokenHash: telegramLoginPGHash(fmt.Sprintf("mini-request-%d", suffix)), + BrowserTokenHash: telegramLoginPGHash(fmt.Sprintf("mini-browser-%d", suffix)), + BotUserID: bot.ID, ClientID: client.ClientID, SigningAlgorithm: client.SigningAlgorithm, + Source: domain.TelegramLoginRequestMiniApp, ResponseType: "post_message", + RedirectURI: origin + "/", Origin: origin, InAppOrigin: origin, Domain: fmt.Sprintf("rp-%d.example", suffix), + Scopes: []domain.TelegramLoginScope{domain.TelegramLoginScopeOpenID, domain.TelegramLoginScopeProfile}, + Browser: "Telegram Mini App", Platform: "Telegram Mini App", IP: "192.0.2.11", Region: "Test Region", + MatchCodes: []string{"🟢", "🔵", "🟠"}, MatchCode: "🔵", MatchCodesFirst: true, + Status: domain.TelegramLoginRequestPending, CreatedAt: now, ExpiresAt: now.Add(5 * time.Minute), + }) + if err != nil { + t.Fatalf("create mini-app request: %v", err) + } + if _, _, err := store.ApproveTelegramLoginRequest(ctx, domain.TelegramLoginApproval{ + RequestID: miniRequest.ID, + Identity: domain.TelegramLoginIdentitySnapshot{UserID: owner.ID, Name: owner.FirstName, GivenName: owner.FirstName}, + MatchCode: miniRequest.MatchCode, ApprovedAt: now.Add(5 * time.Second), + }, suffix+25_000); err != nil { + t.Fatalf("approve mini-app request: %v", err) + } + directToken := domain.TelegramLoginAuthorizationCode{ + RequestID: miniRequest.ID, CodeHash: telegramLoginPGHash(fmt.Sprintf("mini-token-%d", suffix)), + SealedCode: append(make([]byte, 32), 1), SealNonce: make([]byte, 12), SealKeyID: "integration-key", + IssuedAt: now.Add(6 * time.Second), ExpiresAt: now.Add(time.Minute), + } + if _, err := store.PutTelegramLoginAuthorizationCode(ctx, directToken); err != nil { + t.Fatalf("put mini-app token: %v", err) + } + start = make(chan struct{}) + errs = make(chan error, 8) + for range 8 { + wg.Add(1) + go func() { + defer wg.Done() + <-start + _, _, _, err := store.ConsumeTelegramLoginDirectToken(ctx, directToken.CodeHash, origin, now.Add(7*time.Second)) + errs <- err + }() + } + close(start) + wg.Wait() + close(errs) + success, conflict = 0, 0 + for err := range errs { + switch { + case err == nil: + success++ + case errors.Is(err, domain.ErrTelegramLoginCodeConsumed): + conflict++ + default: + t.Fatalf("mini-app token consume race error: %v", err) + } + } + if success != 1 || conflict != 7 { + t.Fatalf("mini-app token consume success=%d consumed=%d, want 1/7", success, conflict) + } + + if revoked, err := store.RevokeTelegramLoginWebAuthorization(ctx, owner.ID, web.Hash, now.Add(5*time.Second)); err != nil || !revoked { + t.Fatalf("revoke web authorization = %v,%v", revoked, err) + } + if listed, err := store.ListTelegramLoginWebAuthorizations(ctx, owner.ID); err != nil { + t.Fatalf("list web authorizations: %v", err) + } else { + for _, got := range listed { + if got.Hash == web.Hash { + t.Fatalf("revoked web authorization still listed: %#v", got) + } + } + } + assertTelegramLoginConfigDeleteTakesClientLock(t, pool, client.BotUserID, func() (bool, error) { + return store.DeleteTelegramLoginAllowedURL(ctx, client.BotUserID, domain.TelegramLoginAllowedRedirectURI, redirectURI) + }) +} + +func TestTelegramLoginStorePostgresNativeCallbackAndRetention(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + now := time.Now().UTC().Truncate(time.Microsecond) + suffix := now.UnixNano() % 1_000_000_000 + + users := NewUserStore(pool) + bots := NewBotStore(pool) + owner, err := users.Create(ctx, domain.User{ + AccessHash: suffix + 301, Phone: fmt.Sprintf("1666%09d", suffix), FirstName: "Native Owner", + }) + if err != nil { + t.Fatal(err) + } + bot, _, err := bots.CreateBotAccount(ctx, domain.User{ + AccessHash: suffix + 302, FirstName: "Native Login Bot", Username: fmt.Sprintf("native_%09d_bot", suffix), + }, domain.BotProfile{OwnerUserID: owner.ID, TokenSecret: "native-bot-secret"}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id IN ($1,$2)", owner.ID, bot.ID) }) + + store := NewTelegramLoginStore(pool) + client, err := store.CreateTelegramLoginClient(ctx, domain.TelegramLoginClient{ + BotUserID: bot.ID, ClientID: fmt.Sprintf("%d", bot.ID), SecretHash: telegramLoginPGHash("native-secret"), + SecretVersion: 1, SigningAlgorithm: domain.TelegramLoginSigningRS256, Enabled: true, + CreatedAt: now, UpdatedAt: now, + }) + if err != nil { + t.Fatal(err) + } + const callbackURI = "bedolaga://telegram-login" + nativeApp, err := store.UpsertTelegramLoginNativeApp(ctx, domain.TelegramLoginNativeApp{ + BotUserID: bot.ID, Platform: domain.TelegramLoginNativeAndroid, ApplicationID: "dev.bedolaga.demo", + VerificationID: strings.Repeat("A", 64), CallbackURI: callbackURI, VerifiedDisplayName: "Bedolaga Demo", + Enabled: true, CreatedAt: now, UpdatedAt: now, + }) + if err != nil { + t.Fatal(err) + } + + createRequest := func(label string) domain.TelegramLoginRequest { + t.Helper() + request, err := store.CreateTelegramLoginRequest(ctx, domain.TelegramLoginRequest{ + RequestTokenHash: telegramLoginPGHash("native-request-" + label), BrowserTokenHash: telegramLoginPGHash("native-browser-" + label), + BotUserID: bot.ID, ClientID: client.ClientID, SigningAlgorithm: client.SigningAlgorithm, + Source: domain.TelegramLoginRequestNative, ResponseType: "code", RedirectURI: callbackURI, + Domain: "dev.bedolaga.demo", Scopes: []domain.TelegramLoginScope{domain.TelegramLoginScopeOpenID, domain.TelegramLoginScopeProfile}, + CodeChallenge: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", CodeChallengeMethod: "S256", + Browser: "TelegramLogin/Android", Platform: "Android", IP: "192.0.2.20", Region: "Test Region", + IsApp: true, VerifiedAppName: "Bedolaga Demo", MatchCodes: []string{}, Status: domain.TelegramLoginRequestPending, + CreatedAt: now, ExpiresAt: now.Add(5 * time.Minute), + }) + if err != nil { + t.Fatalf("create native request: %v", err) + } + return request + } + approve := func(request domain.TelegramLoginRequest, hash int64) domain.TelegramLoginWebAuthorization { + t.Helper() + _, web, err := store.ApproveTelegramLoginRequest(ctx, domain.TelegramLoginApproval{ + RequestID: request.ID, Identity: domain.TelegramLoginIdentitySnapshot{UserID: owner.ID, Name: "Native Owner", GivenName: "Native"}, + ApprovedAt: now.Add(time.Second), + }, hash) + if err != nil { + t.Fatalf("approve native request: %v", err) + } + return web + } + + revokedRequest := createRequest(fmt.Sprintf("revoked-%d", suffix)) + revokedWeb := approve(revokedRequest, suffix+30_000) + code := domain.TelegramLoginAuthorizationCode{ + RequestID: revokedRequest.ID, CodeHash: telegramLoginPGHash(fmt.Sprintf("native-code-%d", suffix)), + SealedCode: append(make([]byte, 32), 1), SealNonce: make([]byte, 12), SealKeyID: "integration-key", + IssuedAt: now.Add(2 * time.Second), ExpiresAt: now.Add(time.Minute), + } + if _, err := store.PutTelegramLoginAuthorizationCode(ctx, code); err != nil { + t.Fatal(err) + } + if _, _, _, err := store.ConsumeTelegramLoginAuthorizationCode(ctx, domain.TelegramLoginCodeExchange{ + CodeHash: code.CodeHash, ClientID: client.ClientID, ClientSecretVersion: client.SecretVersion, + RedirectURI: callbackURI, CodeChallenge: revokedRequest.CodeChallenge, Now: now.Add(3 * time.Second), + }); err != nil { + t.Fatalf("consume native code: %v", err) + } + if ok, err := store.RevokeTelegramLoginWebAuthorization(ctx, owner.ID, revokedWeb.Hash, now.Add(4*time.Second)); err != nil || !ok { + t.Fatalf("revoke native authorization = %v,%v", ok, err) + } + + activeRequest := createRequest(fmt.Sprintf("active-%d", suffix)) + activeWeb := approve(activeRequest, suffix+40_000) + deleted, err := store.DeleteExpiredTelegramLoginArtifacts(ctx, now.Add(2*time.Hour), 100) + if err != nil { + t.Fatal(err) + } + if deleted < 2 { + t.Fatalf("retention deleted=%d, want at least code and revoked request", deleted) + } + if _, found, _ := store.GetTelegramLoginRequest(ctx, revokedRequest.ID); found { + t.Fatal("revoked native request survived retention") + } + if _, found, _ := store.GetTelegramLoginRequest(ctx, activeRequest.ID); !found { + t.Fatal("active native request was deleted") + } + listed, err := store.ListTelegramLoginWebAuthorizations(ctx, owner.ID) + if err != nil || len(listed) != 1 || listed[0].Hash != activeWeb.Hash { + t.Fatalf("active authorization list=%#v err=%v", listed, err) + } + assertTelegramLoginConfigDeleteTakesClientLock(t, pool, client.BotUserID, func() (bool, error) { + return store.DeleteTelegramLoginNativeApp(ctx, client.BotUserID, nativeApp.ID) + }) +} + +func assertTelegramLoginConfigDeleteTakesClientLock(t *testing.T, pool *pgxpool.Pool, botUserID int64, remove func() (bool, error)) { + t.Helper() + ctx := context.Background() + tx, err := pool.Begin(ctx) + if err != nil { + t.Fatal(err) + } + defer func() { _ = tx.Rollback(ctx) }() + var lockedID int64 + if err := tx.QueryRow(ctx, `SELECT bot_user_id FROM bot_login_clients WHERE bot_user_id = $1 FOR UPDATE`, botUserID).Scan(&lockedID); err != nil { + t.Fatal(err) + } + result := make(chan error, 1) + go func() { + deleted, err := remove() + if err == nil && !deleted { + err = errors.New("configuration row was not deleted") + } + result <- err + }() + select { + case err := <-result: + t.Fatalf("configuration delete bypassed client serialization lock: %v", err) + case <-time.After(150 * time.Millisecond): + } + if err := tx.Commit(ctx); err != nil { + t.Fatal(err) + } + select { + case err := <-result: + if err != nil { + t.Fatal(err) + } + case <-time.After(5 * time.Second): + t.Fatal("configuration delete remained blocked after client lock committed") + } +} diff --git a/internal/store/telegram_login.go b/internal/store/telegram_login.go new file mode 100644 index 00000000..7eb8fa48 --- /dev/null +++ b/internal/store/telegram_login.go @@ -0,0 +1,49 @@ +package store + +import ( + "context" + "time" + + "telesrv/internal/domain" +) + +// TelegramLoginStore is the single durable boundary shared by the HTTP OIDC +// adapter, MTProto URL-authorization RPCs and account Web-authorization RPCs. +// Implementations must use compare-and-set transitions and must not treat an +// in-memory cache as the source of truth. +type TelegramLoginStore interface { + CreateTelegramLoginClient(ctx context.Context, client domain.TelegramLoginClient) (domain.TelegramLoginClient, error) + UpsertTelegramLoginClient(ctx context.Context, client domain.TelegramLoginClient) (domain.TelegramLoginClient, error) + GetTelegramLoginClient(ctx context.Context, clientID string) (domain.TelegramLoginClient, bool, error) + GetTelegramLoginClientByBot(ctx context.Context, botUserID int64) (domain.TelegramLoginClient, bool, error) + RotateTelegramLoginClientSecret(ctx context.Context, botUserID, expectedVersion int64, secretHash []byte, now time.Time) (domain.TelegramLoginClient, error) + SetTelegramLoginClientSigningAlgorithm(ctx context.Context, botUserID int64, algorithm domain.TelegramLoginSigningAlgorithm, now time.Time) (domain.TelegramLoginClient, error) + SetTelegramLoginClientEnabled(ctx context.Context, botUserID int64, enabled bool, now time.Time) error + + AddTelegramLoginAllowedURL(ctx context.Context, allowed domain.TelegramLoginAllowedURL) (domain.TelegramLoginAllowedURL, error) + DeleteTelegramLoginAllowedURL(ctx context.Context, botUserID int64, kind domain.TelegramLoginAllowedURLKind, normalizedURL string) (bool, error) + ListTelegramLoginAllowedURLs(ctx context.Context, botUserID int64) ([]domain.TelegramLoginAllowedURL, error) + IsTelegramLoginURLAllowed(ctx context.Context, botUserID int64, kind domain.TelegramLoginAllowedURLKind, normalizedURL string) (bool, error) + + UpsertTelegramLoginNativeApp(ctx context.Context, app domain.TelegramLoginNativeApp) (domain.TelegramLoginNativeApp, error) + DeleteTelegramLoginNativeApp(ctx context.Context, botUserID, appID int64) (bool, error) + ListTelegramLoginNativeApps(ctx context.Context, botUserID int64) ([]domain.TelegramLoginNativeApp, error) + + CreateTelegramLoginRequest(ctx context.Context, request domain.TelegramLoginRequest) (domain.TelegramLoginRequest, error) + GetTelegramLoginRequest(ctx context.Context, requestID int64) (domain.TelegramLoginRequest, bool, error) + GetTelegramLoginRequestByTokenHash(ctx context.Context, tokenHash []byte) (domain.TelegramLoginRequest, bool, error) + GetTelegramLoginRequestByBrowserTokenHash(ctx context.Context, tokenHash []byte) (domain.TelegramLoginRequest, bool, error) + ApproveTelegramLoginRequest(ctx context.Context, approval domain.TelegramLoginApproval, webAuthorizationHash int64) (domain.TelegramLoginRequest, domain.TelegramLoginWebAuthorization, error) + DeclineTelegramLoginRequest(ctx context.Context, requestID, userID int64, now time.Time) (domain.TelegramLoginRequest, error) + + PutTelegramLoginAuthorizationCode(ctx context.Context, code domain.TelegramLoginAuthorizationCode) (domain.TelegramLoginAuthorizationCode, error) + GetTelegramLoginAuthorizationCodeByRequest(ctx context.Context, requestID int64) (domain.TelegramLoginAuthorizationCode, bool, error) + GetTelegramLoginAuthorizationCodeByHash(ctx context.Context, codeHash []byte) (domain.TelegramLoginAuthorizationCode, bool, error) + ConsumeTelegramLoginAuthorizationCode(ctx context.Context, exchange domain.TelegramLoginCodeExchange) (domain.TelegramLoginAuthorizationCode, domain.TelegramLoginRequest, domain.TelegramLoginWebAuthorization, error) + ConsumeTelegramLoginDirectToken(ctx context.Context, tokenHash []byte, origin string, now time.Time) (domain.TelegramLoginAuthorizationCode, domain.TelegramLoginRequest, domain.TelegramLoginWebAuthorization, error) + + ListTelegramLoginWebAuthorizations(ctx context.Context, userID int64) ([]domain.TelegramLoginWebAuthorization, error) + RevokeTelegramLoginWebAuthorization(ctx context.Context, userID, hash int64, now time.Time) (bool, error) + RevokeAllTelegramLoginWebAuthorizations(ctx context.Context, userID int64, now time.Time) (int64, error) + DeleteExpiredTelegramLoginArtifacts(ctx context.Context, before time.Time, limit int) (int64, error) +} diff --git a/internal/telegramloginhttp/handler.go b/internal/telegramloginhttp/handler.go new file mode 100644 index 00000000..9130a5e8 --- /dev/null +++ b/internal/telegramloginhttp/handler.go @@ -0,0 +1,748 @@ +// Package telegramloginhttp is the public HTTP adapter for Telegram Login and +// OpenID Connect. It contains protocol parsing/rendering only; durable state +// and authorization transitions remain in app/telegramlogin and its store. +package telegramloginhttp + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "html/template" + "io" + "mime" + "net" + "net/http" + "net/netip" + "net/url" + "strings" + "time" + "unicode/utf8" + + "go.uber.org/zap" + + loginapp "telesrv/internal/app/telegramlogin" + "telesrv/internal/domain" +) + +const ( + maxAuthorizationQueryBytes = 16 << 10 + maxTokenFormBytes = 16 << 10 + maxStatusFormBytes = 4 << 10 +) + +type Config struct { + Service *loginapp.Service + Tokens *loginapp.IDTokenIssuer + Limiter RateLimiter + AppName string + Logger *zap.Logger + TrustedProxyCIDRs []string + AllowLoopbackHTTP bool +} + +type Handler struct { + service *loginapp.Service + tokens *loginapp.IDTokenIssuer + appName string + logger *zap.Logger + limiter RateLimiter + trustedProxies []netip.Prefix + allowLoopbackHTTP bool + mux *http.ServeMux +} + +type RateLimiter interface { + Allow(ctx context.Context, key string, limit int, window time.Duration) (allowed bool, retryAfterSeconds int, err error) +} + +func NewHandler(cfg Config) (*Handler, error) { + if cfg.Service == nil || cfg.Tokens == nil || cfg.Tokens.Issuer() == "" { + return nil, errors.New("telegram login HTTP dependencies are incomplete") + } + if strings.TrimSpace(cfg.AppName) == "" { + cfg.AppName = "Telesrv" + } + if cfg.Logger == nil { + cfg.Logger = zap.NewNop() + } + trustedProxies := make([]netip.Prefix, 0, len(cfg.TrustedProxyCIDRs)) + for _, raw := range cfg.TrustedProxyCIDRs { + prefix, err := netip.ParsePrefix(strings.TrimSpace(raw)) + if err != nil { + return nil, fmt.Errorf("telegram login trusted proxy CIDR %q: %w", raw, err) + } + trustedProxies = append(trustedProxies, prefix.Masked()) + } + h := &Handler{service: cfg.Service, tokens: cfg.Tokens, appName: strings.TrimSpace(cfg.AppName), logger: cfg.Logger, limiter: cfg.Limiter, trustedProxies: trustedProxies, allowLoopbackHTTP: cfg.AllowLoopbackHTTP} + mux := http.NewServeMux() + mux.HandleFunc("GET /.well-known/openid-configuration", h.discovery) + mux.HandleFunc("GET /.well-known/jwks.json", h.jwks) + mux.HandleFunc("GET /auth", h.authorize) + mux.HandleFunc("GET /crossapp", h.crossApp) + mux.HandleFunc("GET /inapp", h.inApp) + mux.HandleFunc("POST /auth/status", h.authorizationStatus) + mux.HandleFunc("POST /token", h.token) + mux.HandleFunc("GET /telegram-login.js", h.loginJavaScript) + mux.HandleFunc("GET /js/telegram-login.js", h.loginJavaScript) + h.mux = mux + return h, nil +} + +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("Referrer-Policy", "no-referrer") + w.Header().Set("Cross-Origin-Opener-Policy", "same-origin-allow-popups") + h.mux.ServeHTTP(w, r) +} + +type discoveryDocument struct { + Issuer string `json:"issuer"` + AuthorizationEndpoint string `json:"authorization_endpoint"` + TokenEndpoint string `json:"token_endpoint"` + JWKSURI string `json:"jwks_uri"` + ScopesSupported []string `json:"scopes_supported"` + ResponseTypesSupported []string `json:"response_types_supported"` + ResponseModesSupported []string `json:"response_modes_supported"` + GrantTypesSupported []string `json:"grant_types_supported"` + SubjectTypesSupported []string `json:"subject_types_supported"` + IDTokenSigningAlgorithms []string `json:"id_token_signing_alg_values_supported"` + TokenEndpointAuthMethods []string `json:"token_endpoint_auth_methods_supported"` + ClaimsSupported []string `json:"claims_supported"` + CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"` +} + +func (h *Handler) discovery(w http.ResponseWriter, _ *http.Request) { + issuer := h.tokens.Issuer() + writeJSON(w, http.StatusOK, discoveryDocument{ + Issuer: issuer, AuthorizationEndpoint: issuer + "/auth", TokenEndpoint: issuer + "/token", + JWKSURI: issuer + "/.well-known/jwks.json", + ScopesSupported: []string{"openid", "profile", "phone", "telegram:bot_access"}, + ResponseTypesSupported: []string{"code"}, ResponseModesSupported: []string{"query"}, + GrantTypesSupported: []string{"authorization_code"}, SubjectTypesSupported: []string{"public"}, + IDTokenSigningAlgorithms: h.tokens.SupportedAlgorithms(), + TokenEndpointAuthMethods: []string{"client_secret_basic", "client_secret_post", "none"}, + ClaimsSupported: []string{ + "iss", "aud", "sub", "iat", "exp", "nonce", "id", "name", "given_name", + "family_name", "preferred_username", "picture", "phone_number", "phone_number_verified", + }, + CodeChallengeMethodsSupported: []string{"S256"}, + }) +} + +func (h *Handler) jwks(w http.ResponseWriter, r *http.Request) { + body, etag, err := h.tokens.JWKS() + if err != nil { + h.logger.Error("telegram_login_jwks_failed", zap.Error(err)) + writeOAuthError(w, http.StatusInternalServerError, "server_error", "key service unavailable") + return + } + w.Header().Set("Cache-Control", "public, max-age=300, must-revalidate") + w.Header().Set("ETag", etag) + if r.Header.Get("If-None-Match") == etag { + w.WriteHeader(http.StatusNotModified) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(body) +} + +type authorizationPageData struct { + AppName string + DeepLink template.URL + MatchCode string + BrowserToken string + ExpiresAt string + CSPNonce string + ResponseType string + TargetOrigin string +} + +var authorizationPage = template.Must(template.New("telegram-login").Parse(` + +Log in with {{.AppName}} +

Log in with {{.AppName}}

Open the {{.AppName}} app and approve this request. Keep this page open.

Open {{.AppName}}{{if .MatchCode}}

When prompted, select this emoji in {{.AppName}}:

{{end}}

Waiting for approval…

This request expires at {{.ExpiresAt}}.

+`)) + +func (h *Handler) authorize(w http.ResponseWriter, r *http.Request) { + clientIP := h.requestIP(r) + if !h.allow(w, r, "authorize", clientIP, 30, time.Minute) { + return + } + if len(r.URL.RawQuery) > maxAuthorizationQueryBytes { + h.authorizationRequestError(w) + return + } + query := r.URL.Query() + nativePlatform, nativeMarkerOK := nativeSDKPlatform(query) + if !nativeMarkerOK { + h.authorizationRequestError(w) + return + } + values := make(map[string]string, 9) + for _, key := range []string{"client_id", "redirect_uri", "response_type", "scope", "state", "nonce", "code_challenge", "code_challenge_method", "origin"} { + value, ok := singleValue(query, key) + if !ok { + h.authorizationRequestError(w) + return + } + values[key] = value + } + platformLabel := "Web" + if nativePlatform == domain.TelegramLoginNativeIOS { + platformLabel = "iOS" + } else if nativePlatform == domain.TelegramLoginNativeAndroid { + platformLabel = "Android" + } + source := domain.TelegramLoginRequestWeb + if values["response_type"] == "post_message" { + source = domain.TelegramLoginRequestJavaScript + } + created, err := h.service.CreateAuthorization(r.Context(), loginapp.CreateAuthorizationParams{ + ClientID: values["client_id"], RedirectURI: values["redirect_uri"], ResponseType: values["response_type"], + Scope: values["scope"], State: values["state"], Nonce: values["nonce"], + CodeChallenge: values["code_challenge"], CodeChallengeMethod: values["code_challenge_method"], + Origin: values["origin"], + Source: source, NativePlatform: nativePlatform, + Browser: boundedHeader(r.UserAgent(), "Unknown browser", 255), + Platform: platformLabel, IP: clientIP, Region: "Unknown region", IncludeMatchCodes: true, MatchCodesFirst: true, + }) + if err != nil { + h.logger.Info("telegram_login_authorize_rejected", zap.String("error", errorClass(err))) + h.authorizationError(w, r, values, err) + return + } + cspNonce, err := loginapp.GenerateOpaqueToken() + if err != nil { + h.logger.Error("telegram_login_csp_nonce_failed", zap.Error(err)) + h.authorizationRequestError(w) + return + } + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Pragma", "no-cache") + w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; script-src 'nonce-"+cspNonce+"'; connect-src 'self'; form-action 'none'; frame-ancestors 'none'; base-uri 'none'") + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + if err := authorizationPage.Execute(w, authorizationPageData{ + AppName: h.appName, DeepLink: template.URL(created.DeepLink), MatchCode: created.Request.MatchCode, BrowserToken: created.BrowserToken, + ExpiresAt: created.Request.ExpiresAt.UTC().Format(time.RFC3339), CSPNonce: cspNonce, + ResponseType: created.Request.ResponseType, TargetOrigin: created.Request.Origin, + }); err != nil { + h.logger.Warn("telegram_login_authorize_render_failed", zap.Error(err)) + } +} + +func (h *Handler) crossApp(w http.ResponseWriter, r *http.Request) { + clientIP := h.requestIP(r) + if !h.allow(w, r, "crossapp", clientIP, 30, time.Minute) { + return + } + if len(r.URL.RawQuery) > maxAuthorizationQueryBytes { + writeOAuthError(w, http.StatusBadRequest, "invalid_request", "invalid native login request") + return + } + query := r.URL.Query() + platform, ok := nativeSDKPlatform(query) + if !ok || !platform.Valid() { + writeOAuthError(w, http.StatusBadRequest, "invalid_request", "exactly one native SDK marker is required") + return + } + values := make(map[string]string, 8) + for _, key := range []string{"client_id", "redirect_uri", "response_type", "scope", "state", "nonce", "code_challenge", "code_challenge_method"} { + value, unique := singleValue(query, key) + if !unique { + writeOAuthError(w, http.StatusBadRequest, "invalid_request", "duplicate parameter") + return + } + values[key] = value + } + platformLabel := "iOS" + if platform == domain.TelegramLoginNativeAndroid { + platformLabel = "Android" + } + created, err := h.service.CreateAuthorization(r.Context(), loginapp.CreateAuthorizationParams{ + ClientID: values["client_id"], RedirectURI: values["redirect_uri"], ResponseType: values["response_type"], + Scope: values["scope"], State: values["state"], Nonce: values["nonce"], + CodeChallenge: values["code_challenge"], CodeChallengeMethod: values["code_challenge_method"], + Source: domain.TelegramLoginRequestNative, NativePlatform: platform, + Browser: boundedHeader(r.UserAgent(), "Native SDK", 255), Platform: platformLabel, + IP: clientIP, Region: "Unknown region", IncludeMatchCodes: true, MatchCodesFirst: true, + }) + if err != nil { + h.logger.Info("telegram_login_crossapp_rejected", zap.String("error", errorClass(err))) + writeOAuthError(w, http.StatusBadRequest, "invalid_request", "native login request is not registered") + return + } + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Pragma", "no-cache") + writeJSON(w, http.StatusOK, map[string]string{"url": created.DeepLink}) +} + +func (h *Handler) inApp(w http.ResponseWriter, r *http.Request) { + clientIP := h.requestIP(r) + if !h.allow(w, r, "inapp", clientIP, 60, time.Minute) { + return + } + if len(r.URL.RawQuery) > maxAuthorizationQueryBytes { + writeOAuthError(w, http.StatusBadRequest, "invalid_request", "invalid in-app login request") + return + } + query := r.URL.Query() + if rawCode, exists := query["code"]; exists { + if len(query) != 1 || len(rawCode) != 1 || rawCode[0] == "" { + writeOAuthError(w, http.StatusBadRequest, "invalid_request", "invalid in-app token") + return + } + issued, err := h.service.ExchangeInAppTokenAndIssue(r.Context(), rawCode[0], r.Header.Get("Origin"), h.tokens) + if err != nil { + h.logger.Info("telegram_login_inapp_exchange_rejected", zap.String("error", errorClass(err))) + writeOAuthError(w, http.StatusBadRequest, "invalid_grant", "in-app token is invalid or expired") + return + } + setInAppCORS(w, issued.Request.InAppOrigin) + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Pragma", "no-cache") + writeJSON(w, http.StatusOK, map[string]string{"result": issued.IDToken}) + return + } + if len(query) != 4 { + writeOAuthError(w, http.StatusBadRequest, "invalid_request", "invalid in-app login request") + return + } + values := make(map[string]string, 4) + for _, key := range []string{"client_id", "scope", "origin", "response_type"} { + value, unique := singleValue(query, key) + if !unique || value == "" { + writeOAuthError(w, http.StatusBadRequest, "invalid_request", "invalid in-app login request") + return + } + values[key] = value + } + if values["response_type"] != "id_token" { + writeOAuthError(w, http.StatusBadRequest, "unsupported_response_type", "only id_token is supported") + return + } + origin, err := loginapp.NormalizeWebOrigin(values["origin"], h.allowLoopbackHTTP) + if err != nil || r.Header.Get("Origin") != origin { + writeOAuthError(w, http.StatusBadRequest, "invalid_request", "in-app origin is invalid") + return + } + setInAppCORS(w, origin) + created, err := h.service.CreateAuthorization(r.Context(), loginapp.CreateAuthorizationParams{ + ClientID: values["client_id"], RedirectURI: origin + "/", ResponseType: "post_message", + Scope: values["scope"], Origin: origin, InAppOrigin: origin, + Source: domain.TelegramLoginRequestMiniApp, + Browser: boundedHeader(r.UserAgent(), "Telegram Mini App", 255), Platform: "Telegram Mini App", + IP: clientIP, Region: "Unknown region", IncludeMatchCodes: true, MatchCodesFirst: true, + }) + if err != nil { + h.logger.Info("telegram_login_inapp_rejected", zap.String("error", errorClass(err))) + writeOAuthError(w, http.StatusBadRequest, "invalid_request", "in-app login request is not registered") + return + } + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Pragma", "no-cache") + writeJSON(w, http.StatusOK, map[string]string{"url": created.DeepLink}) +} + +func setInAppCORS(w http.ResponseWriter, origin string) { + w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Set("Vary", "Origin") +} + +func nativeSDKPlatform(values url.Values) (domain.TelegramLoginNativePlatform, bool) { + ios, iosUnique := singleValue(values, "ios_sdk") + android, androidUnique := singleValue(values, "android_sdk") + if !iosUnique || !androidUnique || (ios != "" && ios != "1") || (android != "" && android != "1") || (ios != "" && android != "") { + return "", false + } + if ios == "1" { + return domain.TelegramLoginNativeIOS, true + } + if android == "1" { + return domain.TelegramLoginNativeAndroid, true + } + return "", true +} + +var authorizationErrorPage = template.Must(template.New("telegram-login-error").Parse(`Telegram Login

Login could not be started.

`)) + +func (h *Handler) authorizationError(w http.ResponseWriter, r *http.Request, values map[string]string, cause error) { + target, safe, err := h.service.ResolveAuthorizationErrorTarget(r.Context(), values["client_id"], values["response_type"], values["redirect_uri"], values["origin"]) + if err != nil || !safe { + if err != nil { + h.logger.Error("telegram_login_authorize_error_target_failed", zap.String("error", errorClass(err))) + } + h.authorizationRequestError(w) + return + } + code := "invalid_request" + switch { + case errors.Is(cause, domain.ErrTelegramLoginScopeInvalid): + code = "invalid_scope" + case values["response_type"] != "code" && values["response_type"] != "post_message": + code = "unsupported_response_type" + default: + // Known validation failures use invalid_request. Unknown failures are + // reported as server_error without exposing their details. + if strings.HasPrefix(errorClass(cause), "internal_") { + code = "server_error" + } + } + state := values["state"] + if len(state) > 2048 { + state = "" + } + if target.ResponseType == "code" { + redirectURL, err := loginapp.AppendAuthorizationError(target.RedirectURI, code, state) + if err != nil { + h.authorizationRequestError(w) + return + } + w.Header().Set("Cache-Control", "no-store") + http.Redirect(w, r, redirectURL, http.StatusFound) + return + } + nonce, err := loginapp.GenerateOpaqueToken() + if err != nil { + h.authorizationRequestError(w) + return + } + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Content-Security-Policy", "default-src 'none'; script-src 'nonce-"+nonce+"'; frame-ancestors 'none'; base-uri 'none'") + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + _ = authorizationErrorPage.Execute(w, struct { + Nonce string + Error string + Origin string + }{Nonce: nonce, Error: code, Origin: target.Origin}) +} + +func (h *Handler) authorizationRequestError(w http.ResponseWriter) { + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + http.Error(w, "Invalid Telegram Login request.", http.StatusBadRequest) +} + +func (h *Handler) authorizationStatus(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Pragma", "no-cache") + if !h.allow(w, r, "status", h.requestIP(r), 300, time.Minute) { + return + } + form, ok := parseBoundedForm(w, r, maxStatusFormBytes) + if !ok { + return + } + browserToken, unique := singleValue(form, "browser_token") + if !unique || browserToken == "" { + writeOAuthError(w, http.StatusBadRequest, "invalid_request", "invalid browser request") + return + } + request, err := h.service.RequestByBrowserToken(r.Context(), browserToken) + if err != nil { + writeOAuthError(w, http.StatusBadRequest, "invalid_request", "invalid browser request") + return + } + switch request.Status { + case domain.TelegramLoginRequestPending: + writeJSON(w, http.StatusOK, map[string]string{"status": "pending"}) + case domain.TelegramLoginRequestApproved: + if request.ResponseType == "post_message" { + finalized, err := h.service.FinalizeDirectByBrowserToken(r.Context(), browserToken, h.tokens) + if err != nil { + h.logger.Error("telegram_login_direct_finalize_failed", zap.Int64("request_id", request.ID), zap.Error(err)) + writeOAuthError(w, http.StatusInternalServerError, "server_error", "authorization could not be finalized") + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "approved", "id_token": finalized.IDToken}) + return + } + finalized, err := h.service.FinalizeByBrowserToken(r.Context(), browserToken) + if err != nil { + h.logger.Error("telegram_login_finalize_failed", zap.Int64("request_id", request.ID), zap.Error(err)) + writeOAuthError(w, http.StatusInternalServerError, "server_error", "authorization could not be finalized") + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "approved", "redirect_url": finalized.RedirectURL}) + case domain.TelegramLoginRequestDeclined, domain.TelegramLoginRequestExpired: + errorCode := "access_denied" + status := "declined" + if request.Status == domain.TelegramLoginRequestExpired { + errorCode, status = "temporarily_unavailable", "expired" + } + if request.ResponseType == "post_message" { + writeJSON(w, http.StatusOK, map[string]string{"status": status, "error": errorCode}) + return + } + redirectURL, err := loginapp.AppendAuthorizationError(request.RedirectURI, errorCode, request.State) + if err != nil { + writeOAuthError(w, http.StatusInternalServerError, "server_error", "authorization could not be finalized") + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": status, "redirect_url": redirectURL}) + default: + writeOAuthError(w, http.StatusBadRequest, "invalid_request", "invalid browser request") + } +} + +func (h *Handler) token(w http.ResponseWriter, r *http.Request) { + if !h.allow(w, r, "token-ip", h.requestIP(r), 60, time.Minute) { + return + } + form, ok := parseBoundedForm(w, r, maxTokenFormBytes) + if !ok { + return + } + authorizationHeaders := r.Header.Values("Authorization") + var clientID, clientSecret string + var publicNativeClient bool + switch len(authorizationHeaders) { + case 0: + var unique bool + clientID, unique = requiredSingleValue(form, "client_id", 64) + if !unique { + h.invalidClient(w) + return + } + clientSecret, unique = optionalSingleValue(form, "client_secret") + if !unique || len(clientSecret) > 1024 { + h.invalidClient(w) + return + } + publicNativeClient = clientSecret == "" + case 1: + var ok bool + clientID, clientSecret, ok = r.BasicAuth() + if !ok || clientID == "" || clientSecret == "" || len(clientID) > 64 || len(clientSecret) > 1024 { + h.invalidClient(w) + return + } + if _, supplied := form["client_secret"]; supplied { + writeOAuthError(w, http.StatusBadRequest, "invalid_request", "multiple client authentication methods are not allowed") + return + } + default: + h.invalidClient(w) + return + } + if !h.allow(w, r, "token-client", clientID, 60, time.Minute) { + return + } + formClientID, unique := optionalSingleValue(form, "client_id") + if !unique || (formClientID != "" && formClientID != clientID) { + h.invalidClient(w) + return + } + grantType, okGrant := requiredSingleValue(form, "grant_type", 64) + if !okGrant { + writeOAuthError(w, http.StatusBadRequest, "invalid_request", "grant_type is required") + return + } + if grantType != "authorization_code" { + writeOAuthError(w, http.StatusBadRequest, "unsupported_grant_type", "only authorization_code is supported") + return + } + code, okCode := requiredSingleValue(form, "code", 1024) + redirectURI, okRedirect := requiredSingleValue(form, "redirect_uri", 4096) + codeVerifier, okVerifier := requiredSingleValue(form, "code_verifier", 128) + if !okCode || !okRedirect || !okVerifier { + writeOAuthError(w, http.StatusBadRequest, "invalid_request", "code, redirect_uri and code_verifier are required") + return + } + // Generate every response artifact before consuming the one-time code. A + // transient entropy failure must leave the grant retryable. + accessToken, err := loginapp.GenerateOpaqueToken() + if err != nil { + h.logger.Error("telegram_login_access_token_failed", zap.Error(err)) + writeOAuthError(w, http.StatusInternalServerError, "server_error", "token service unavailable") + return + } + issued, err := h.service.ExchangeAuthorizationCodeAndIssue(r.Context(), loginapp.ExchangeAuthorizationCodeParams{ + Code: code, ClientID: clientID, ClientSecret: clientSecret, RedirectURI: redirectURI, CodeVerifier: codeVerifier, + PublicNativeClient: publicNativeClient, + }, h.tokens) + if err != nil { + switch { + case errors.Is(err, domain.ErrTelegramLoginSecretInvalid), errors.Is(err, domain.ErrTelegramLoginClientDisabled): + h.invalidClient(w) + case errors.Is(err, domain.ErrTelegramLoginCodeInvalid), errors.Is(err, domain.ErrTelegramLoginCodeConsumed), + errors.Is(err, domain.ErrTelegramLoginPKCEInvalid), errors.Is(err, domain.ErrTelegramLoginURLInvalid): + writeOAuthError(w, http.StatusBadRequest, "invalid_grant", "authorization code is invalid or expired") + default: + h.logger.Error("telegram_login_token_exchange_failed", zap.String("error", errorClass(err))) + writeOAuthError(w, http.StatusInternalServerError, "server_error", "token service unavailable") + } + return + } + _ = issued.WebAuthorization // durable authorization is intentionally not encoded into the opaque access token. + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Pragma", "no-cache") + writeJSON(w, http.StatusOK, map[string]any{ + "access_token": accessToken, "token_type": "Bearer", "expires_in": int64(h.tokens.TTL().Seconds()), + "id_token": issued.IDToken, + }) +} + +func (h *Handler) invalidClient(w http.ResponseWriter) { + w.Header().Set("WWW-Authenticate", `Basic realm="telegram-login-token", charset="UTF-8"`) + writeOAuthError(w, http.StatusUnauthorized, "invalid_client", "client authentication failed") +} + +func parseBoundedForm(w http.ResponseWriter, r *http.Request, maxBytes int64) (url.Values, bool) { + mediaType, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")) + if err != nil || mediaType != "application/x-www-form-urlencoded" { + writeOAuthError(w, http.StatusUnsupportedMediaType, "invalid_request", "form content type is required") + return nil, false + } + r.Body = http.MaxBytesReader(w, r.Body, maxBytes) + body, err := io.ReadAll(r.Body) + if err != nil { + writeOAuthError(w, http.StatusBadRequest, "invalid_request", "request body is invalid") + return nil, false + } + form, err := url.ParseQuery(string(body)) + if err != nil { + writeOAuthError(w, http.StatusBadRequest, "invalid_request", "request body is invalid") + return nil, false + } + return form, true +} + +func singleValue(values url.Values, key string) (string, bool) { + items, exists := values[key] + if !exists { + return "", true + } + if len(items) != 1 { + return "", false + } + return items[0], true +} + +func optionalSingleValue(values url.Values, key string) (string, bool) { + value, ok := singleValue(values, key) + return value, ok +} + +func requiredSingleValue(values url.Values, key string, max int) (string, bool) { + value, ok := singleValue(values, key) + return value, ok && value != "" && len(value) <= max +} + +func boundedHeader(value, fallback string, max int) string { + value = strings.TrimSpace(strings.ToValidUTF8(value, "�")) + if value == "" { + value = fallback + } + for len(value) > max { + _, size := utf8.DecodeLastRuneInString(value) + value = value[:len(value)-size] + } + return value +} + +func (h *Handler) requestIP(r *http.Request) string { + remote, ok := parseRequestIP(r.RemoteAddr) + if !ok { + return "Unknown IP" + } + if !prefixContains(h.trustedProxies, remote) { + return remote.String() + } + forwarded := strings.Split(r.Header.Get("X-Forwarded-For"), ",") + for i := len(forwarded) - 1; i >= 0; i-- { + candidate, ok := parseRequestIP(strings.TrimSpace(forwarded[i])) + if !ok { + continue + } + remote = candidate + if !prefixContains(h.trustedProxies, candidate) { + return candidate.String() + } + } + if candidate, ok := parseRequestIP(strings.TrimSpace(r.Header.Get("X-Real-IP"))); ok { + return candidate.String() + } + return remote.String() +} + +func parseRequestIP(raw string) (netip.Addr, bool) { + if addrPort, err := netip.ParseAddrPort(raw); err == nil { + return addrPort.Addr().Unmap(), true + } + if host, _, err := net.SplitHostPort(raw); err == nil { + raw = host + } + addr, err := netip.ParseAddr(strings.Trim(raw, "[]")) + return addr.Unmap(), err == nil +} + +func prefixContains(prefixes []netip.Prefix, addr netip.Addr) bool { + for _, prefix := range prefixes { + if prefix.Contains(addr) { + return true + } + } + return false +} + +func (h *Handler) allow(w http.ResponseWriter, r *http.Request, bucket, subject string, limit int, window time.Duration) bool { + if h.limiter == nil { + return true + } + sum := sha256.Sum256([]byte(subject)) + key := "telegram-login-http:" + bucket + ":" + base64.RawURLEncoding.EncodeToString(sum[:]) + allowed, retryAfter, err := h.limiter.Allow(r.Context(), key, limit, window) + if err != nil { + h.logger.Error("telegram_login_rate_limit_failed", zap.String("bucket", bucket), zap.Error(err)) + writeOAuthError(w, http.StatusServiceUnavailable, "temporarily_unavailable", "login service temporarily unavailable") + return false + } + if allowed { + return true + } + if retryAfter <= 0 { + retryAfter = 1 + } + w.Header().Set("Retry-After", fmt.Sprintf("%d", retryAfter)) + writeOAuthError(w, http.StatusTooManyRequests, "temporarily_unavailable", "too many login requests") + return false +} + +func writeJSON(w http.ResponseWriter, status int, value any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(value) +} + +func writeOAuthError(w http.ResponseWriter, status int, code, description string) { + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Pragma", "no-cache") + writeJSON(w, status, map[string]string{"error": code, "error_description": description}) +} + +func errorClass(err error) string { + for _, candidate := range []struct { + target error + name string + }{ + {domain.ErrTelegramLoginClientInvalid, "client_invalid"}, + {domain.ErrTelegramLoginClientDisabled, "client_disabled"}, + {domain.ErrTelegramLoginRedirectNotAllowed, "redirect_not_allowed"}, + {domain.ErrTelegramLoginOriginNotAllowed, "origin_not_allowed"}, + {domain.ErrTelegramLoginScopeInvalid, "scope_invalid"}, + {domain.ErrTelegramLoginPKCEInvalid, "pkce_invalid"}, + {domain.ErrTelegramLoginRequestInvalid, "request_invalid"}, + } { + if errors.Is(err, candidate.target) { + return candidate.name + } + } + return fmt.Sprintf("internal_%T", err) +} diff --git a/internal/telegramloginhttp/handler_test.go b/internal/telegramloginhttp/handler_test.go new file mode 100644 index 00000000..c3dd05bd --- /dev/null +++ b/internal/telegramloginhttp/handler_test.go @@ -0,0 +1,675 @@ +package telegramloginhttp + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "encoding/json" + "html" + "io" + "net/http" + "net/http/httptest" + "net/netip" + "net/url" + "regexp" + "strings" + "sync" + "testing" + "time" + "unicode/utf8" + + "github.com/lestrrat-go/jwx/v3/jwk" + "github.com/lestrrat-go/jwx/v3/jwt" + + loginapp "telesrv/internal/app/telegramlogin" + "telesrv/internal/domain" + "telesrv/internal/store/memory" +) + +func TestRequestIPTrustsForwardingHeadersOnlyFromConfiguredProxies(t *testing.T) { + h := &Handler{trustedProxies: []netip.Prefix{netip.MustParsePrefix("127.0.0.1/32"), netip.MustParsePrefix("10.0.0.0/8")}} + req := httptest.NewRequest(http.MethodGet, "https://oauth.test/auth", nil) + req.RemoteAddr = "127.0.0.1:44321" + req.Header.Set("X-Forwarded-For", "198.51.100.7, 10.0.0.4") + if got := h.requestIP(req); got != "198.51.100.7" { + t.Fatalf("trusted proxy client IP = %q", got) + } + req.RemoteAddr = "203.0.113.9:44321" + req.Header.Set("X-Forwarded-For", "198.51.100.8") + if got := h.requestIP(req); got != "203.0.113.9" { + t.Fatalf("untrusted spoofed client IP = %q", got) + } +} + +func TestBoundedHeaderPreservesValidUTF8AtByteLimit(t *testing.T) { + got := boundedHeader(strings.Repeat("界", 100)+string([]byte{0xff}), "fallback", 255) + if !utf8.ValidString(got) || len(got) > 255 || got == "" { + t.Fatalf("bounded header len=%d valid=%v value=%q", len(got), utf8.ValidString(got), got) + } +} + +type telegramLoginHTTPFixture struct { + handler *Handler + service *loginapp.Service + credentials loginapp.ClientCredentials + redirectURI string + verifier string + challenge string + now *time.Time +} + +type telegramLoginHTTPDenyLimiter struct{} + +func (telegramLoginHTTPDenyLimiter) Allow(context.Context, string, int, time.Duration) (bool, int, error) { + return false, 17, nil +} + +func newTelegramLoginHTTPFixture(t *testing.T) telegramLoginHTTPFixture { + t.Helper() + now := time.Date(2026, 7, 20, 10, 0, 0, 0, time.UTC) + sealKey := make([]byte, 32) + sealKey[0] = 1 + sealer, err := loginapp.NewCodeSealer("test", map[string][]byte{"test": sealKey}) + if err != nil { + t.Fatal(err) + } + pepper := make([]byte, 32) + pepper[0] = 2 + service, err := loginapp.NewService(memory.NewTelegramLoginStore(nil), sealer, loginapp.Config{ + Issuer: "https://oauth.telesrv.test", AppScheme: "telesrv", ClientSecretPepper: pepper, + Now: func() time.Time { return now }, + }) + if err != nil { + t.Fatal(err) + } + credentials, err := service.CreateClient(context.Background(), 9001, domain.TelegramLoginSigningRS256) + if err != nil { + t.Fatal(err) + } + const redirectURI = "https://rp.example/callback" + if _, err := service.AddAllowedURL(context.Background(), 9001, domain.TelegramLoginAllowedRedirectURI, redirectURI); err != nil { + t.Fatal(err) + } + if _, err := service.AddAllowedURL(context.Background(), 9001, domain.TelegramLoginAllowedWebOrigin, "https://rp.example"); err != nil { + t.Fatal(err) + } + signingKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + ring, err := loginapp.NewSigningKeyRing([]loginapp.SigningKeyMaterial{{ + Algorithm: domain.TelegramLoginSigningRS256, KeyID: "rsa-test", PrivateKey: signingKey, Active: true, + }}, func() time.Time { return now }) + if err != nil { + t.Fatal(err) + } + tokens, err := loginapp.NewIDTokenIssuer(ring, loginapp.IDTokenIssuerConfig{ + Issuer: "https://oauth.telesrv.test", Now: func() time.Time { return now }, + }) + if err != nil { + t.Fatal(err) + } + handler, err := NewHandler(Config{Service: service, Tokens: tokens, AppName: "Telesrv", AllowLoopbackHTTP: true}) + if err != nil { + t.Fatal(err) + } + const verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + challenge, err := loginapp.PKCEChallenge(verifier) + if err != nil { + t.Fatal(err) + } + return telegramLoginHTTPFixture{ + handler: handler, service: service, credentials: credentials, redirectURI: redirectURI, + verifier: verifier, challenge: challenge, now: &now, + } +} + +func (f telegramLoginHTTPFixture) authorize(t *testing.T) (browserToken, deepLink string) { + t.Helper() + query := url.Values{ + "client_id": {f.credentials.Client.ClientID}, "redirect_uri": {f.redirectURI}, + "response_type": {"code"}, "scope": {"openid profile phone telegram:bot_access"}, + "state": {"state-value"}, "nonce": {"nonce-value"}, "code_challenge": {f.challenge}, + "code_challenge_method": {"S256"}, + } + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "/auth?"+query.Encode(), nil) + request.RemoteAddr = "192.0.2.10:4242" + f.handler.ServeHTTP(recorder, request) + if recorder.Code != http.StatusOK { + t.Fatalf("authorize status=%d body=%s", recorder.Code, recorder.Body.String()) + } + body := recorder.Body.String() + tokenMatch := regexp.MustCompile(`const token=("[^"]+")`).FindStringSubmatch(body) + if len(tokenMatch) != 2 || json.Unmarshal([]byte(tokenMatch[1]), &browserToken) != nil { + t.Fatalf("browser token not found in page: %s", body) + } + deepLinkMatch := regexp.MustCompile(`href="([^"]+)"`).FindStringSubmatch(body) + if len(deepLinkMatch) != 2 { + t.Fatalf("deep link not found in page: %s", body) + } + deepLink = html.UnescapeString(deepLinkMatch[1]) + pending, err := f.service.RequestByDeepLink(context.Background(), deepLink) + if err != nil { + t.Fatalf("resolve authorization page deep link: %v", err) + } + if pending.MatchCode == "" || !strings.Contains(body, `id="match-code"`) || !strings.Contains(body, pending.MatchCode) { + t.Fatalf("matching emoji missing from authorization page: match=%q body=%s", pending.MatchCode, body) + } + if !strings.Contains(body, "poll();") { + t.Fatalf("authorization status polling is not started: %s", body) + } + return browserToken, deepLink +} + +func TestAuthorizationErrorsUseOnlyPreRegisteredTargets(t *testing.T) { + f := newTelegramLoginHTTPFixture(t) + base := url.Values{ + "client_id": {f.credentials.Client.ClientID}, "redirect_uri": {f.redirectURI}, + "response_type": {"code"}, "scope": {"openid unsupported"}, "state": {"safe-state"}, + "code_challenge": {f.challenge}, "code_challenge_method": {"S256"}, + } + recorder := httptest.NewRecorder() + f.handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/auth?"+base.Encode(), nil)) + if recorder.Code != http.StatusFound { + t.Fatalf("valid redirect error status=%d body=%s", recorder.Code, recorder.Body.String()) + } + location, err := url.Parse(recorder.Header().Get("Location")) + if err != nil || location.Scheme+"://"+location.Host+location.Path != f.redirectURI || location.Query().Get("error") != "invalid_scope" || location.Query().Get("state") != "safe-state" { + t.Fatalf("error redirect=%q err=%v", recorder.Header().Get("Location"), err) + } + + forged := cloneURLValues(base) + forged.Set("redirect_uri", "https://attacker.example/callback") + recorder = httptest.NewRecorder() + f.handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/auth?"+forged.Encode(), nil)) + if recorder.Code != http.StatusBadRequest || recorder.Header().Get("Location") != "" { + t.Fatalf("forged redirect status=%d location=%q", recorder.Code, recorder.Header().Get("Location")) + } + + post := cloneURLValues(base) + post.Set("redirect_uri", "https://rp.example/") + post.Set("response_type", "post_message") + post.Set("origin", "https://rp.example") + recorder = httptest.NewRecorder() + f.handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/auth?"+post.Encode(), nil)) + if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), `postMessage`) || !strings.Contains(recorder.Body.String(), `https://rp.example`) { + t.Fatalf("post_message error status=%d body=%s", recorder.Code, recorder.Body.String()) + } + post.Set("redirect_uri", "https://attacker.example/") + recorder = httptest.NewRecorder() + f.handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/auth?"+post.Encode(), nil)) + if recorder.Code != http.StatusBadRequest { + t.Fatalf("cross-origin post_message status=%d body=%s", recorder.Code, recorder.Body.String()) + } +} + +func cloneURLValues(in url.Values) url.Values { + out := make(url.Values, len(in)) + for key, values := range in { + out[key] = append([]string(nil), values...) + } + return out +} + +func (f telegramLoginHTTPFixture) approveAndFinalize(t *testing.T) (code string) { + t.Helper() + browserToken, deepLink := f.authorize(t) + *f.now = f.now.Add(time.Second) + pending, err := f.service.RequestByDeepLink(context.Background(), deepLink) + if err != nil { + t.Fatalf("RequestByDeepLink(%q): %v", deepLink, err) + } + _, _, err = f.service.Approve(context.Background(), deepLink, domain.TelegramLoginIdentitySnapshot{ + UserID: 42, Name: "Alice Example", GivenName: "Alice", FamilyName: "Example", + PreferredUsername: "alice", Picture: "https://oauth.telesrv.test/userpic/42", PhoneNumber: "+1 555 123 4567", + }, true, true, pending.MatchCode) + if err != nil { + t.Fatal(err) + } + form := url.Values{"browser_token": {browserToken}} + recorder := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/auth/status", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + f.handler.ServeHTTP(recorder, req) + if recorder.Code != http.StatusOK { + t.Fatalf("status status=%d body=%s", recorder.Code, recorder.Body.String()) + } + var response struct { + Status string `json:"status"` + RedirectURL string `json:"redirect_url"` + } + if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil || response.Status != "approved" { + t.Fatalf("status response=%+v err=%v body=%s", response, err, recorder.Body.String()) + } + redirect, err := url.Parse(response.RedirectURL) + if err != nil || redirect.Query().Get("state") != "state-value" { + t.Fatalf("redirect=%q err=%v", response.RedirectURL, err) + } + return redirect.Query().Get("code") +} + +func TestDiscoveryAndJWKS(t *testing.T) { + f := newTelegramLoginHTTPFixture(t) + for _, path := range []string{"/.well-known/openid-configuration", "/.well-known/jwks.json"} { + recorder := httptest.NewRecorder() + f.handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, path, nil)) + if recorder.Code != http.StatusOK || recorder.Header().Get("X-Content-Type-Options") != "nosniff" { + t.Fatalf("GET %s status=%d headers=%v body=%s", path, recorder.Code, recorder.Header(), recorder.Body.String()) + } + } +} + +func TestAuthorizationCodeHTTPFlowAndReplay(t *testing.T) { + f := newTelegramLoginHTTPFixture(t) + code := f.approveAndFinalize(t) + form := url.Values{ + "grant_type": {"authorization_code"}, "code": {code}, "redirect_uri": {f.redirectURI}, + "client_id": {f.credentials.Client.ClientID}, "code_verifier": {f.verifier}, + } + exchange := func() *httptest.ResponseRecorder { + recorder := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/token", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.SetBasicAuth(f.credentials.Client.ClientID, f.credentials.Secret) + f.handler.ServeHTTP(recorder, req) + return recorder + } + recorder := exchange() + if recorder.Code != http.StatusOK || recorder.Header().Get("Cache-Control") != "no-store" { + t.Fatalf("token status=%d headers=%v body=%s", recorder.Code, recorder.Header(), recorder.Body.String()) + } + var response struct { + AccessToken string `json:"access_token"` + IDToken string `json:"id_token"` + TokenType string `json:"token_type"` + ExpiresIn int64 `json:"expires_in"` + } + if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil || response.AccessToken == "" || response.IDToken == "" || response.TokenType != "Bearer" || response.ExpiresIn != 3600 { + t.Fatalf("token response=%+v err=%v body=%s", response, err, recorder.Body.String()) + } + jwksRecorder := httptest.NewRecorder() + f.handler.ServeHTTP(jwksRecorder, httptest.NewRequest(http.MethodGet, "/.well-known/jwks.json", nil)) + set, err := jwk.Parse(jwksRecorder.Body.Bytes()) + if err != nil { + t.Fatal(err) + } + token, err := jwt.Parse([]byte(response.IDToken), jwt.WithKeySet(set), jwt.WithValidate(false)) + if err != nil || !token.Has("phone_number") || !token.Has("preferred_username") { + t.Fatalf("verified ID token=%v err=%v", token, err) + } + replay := exchange() + if replay.Code != http.StatusBadRequest || !strings.Contains(replay.Body.String(), "invalid_grant") { + t.Fatalf("replay status=%d body=%s", replay.Code, replay.Body.String()) + } +} + +func TestNativeSDKCrossAppAndPublicPKCEExchange(t *testing.T) { + f := newTelegramLoginHTTPFixture(t) + const callbackURI = "bedolaga://telegram-login" + if _, err := f.service.AddNativeApp(context.Background(), 9001, domain.TelegramLoginNativeIOS, + "dev.bedolaga.demo", "ABCDE12345", callbackURI, "Bedolaga Demo"); err != nil { + t.Fatalf("AddNativeApp: %v", err) + } + query := url.Values{ + "client_id": {f.credentials.Client.ClientID}, "redirect_uri": {callbackURI}, + "response_type": {"code"}, "scope": {"profile"}, "ios_sdk": {"1"}, + "code_challenge": {f.challenge}, "code_challenge_method": {"S256"}, + } + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "/crossapp?"+query.Encode(), nil) + request.Header.Set("User-Agent", "TelegramLogin/iOS") + f.handler.ServeHTTP(recorder, request) + if recorder.Code != http.StatusOK || recorder.Header().Get("Cache-Control") != "no-store" { + t.Fatalf("crossapp status=%d headers=%v body=%s", recorder.Code, recorder.Header(), recorder.Body.String()) + } + var crossApp struct { + URL string `json:"url"` + } + if err := json.Unmarshal(recorder.Body.Bytes(), &crossApp); err != nil || crossApp.URL == "" { + t.Fatalf("crossapp response=%+v err=%v", crossApp, err) + } + pending, err := f.service.RequestByDeepLink(context.Background(), crossApp.URL) + if err != nil { + t.Fatal(err) + } + if pending.Source != domain.TelegramLoginRequestNative || !pending.IsApp || pending.VerifiedAppName != "Bedolaga Demo" || + len(pending.Scopes) != 2 || pending.Scopes[0] != domain.TelegramLoginScopeOpenID { + t.Fatalf("native request=%#v", pending) + } + *f.now = f.now.Add(time.Second) + if _, _, err := f.service.Approve(context.Background(), crossApp.URL, domain.TelegramLoginIdentitySnapshot{ + UserID: 42, Name: "Alice", GivenName: "Alice", + }, false, false, pending.MatchCode); err != nil { + t.Fatal(err) + } + redirectURL, err := f.service.FinalizeRedirectByDeepLink(context.Background(), crossApp.URL) + if err != nil { + t.Fatal(err) + } + callback, err := url.Parse(redirectURL) + if err != nil || callback.Scheme != "bedolaga" || callback.Host != "telegram-login" || callback.Query().Get("code") == "" { + t.Fatalf("native callback=%q err=%v", redirectURL, err) + } + form := url.Values{ + "grant_type": {"authorization_code"}, "client_id": {f.credentials.Client.ClientID}, + "code": {callback.Query().Get("code")}, "redirect_uri": {callbackURI}, "code_verifier": {f.verifier}, + } + tokenRecorder := httptest.NewRecorder() + tokenRequest := httptest.NewRequest(http.MethodPost, "/token", strings.NewReader(form.Encode())) + tokenRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded") + f.handler.ServeHTTP(tokenRecorder, tokenRequest) + if tokenRecorder.Code != http.StatusOK || !strings.Contains(tokenRecorder.Body.String(), `"id_token"`) { + t.Fatalf("native token status=%d body=%s", tokenRecorder.Code, tokenRecorder.Body.String()) + } + + query.Set("android_sdk", "1") + recorder = httptest.NewRecorder() + f.handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/crossapp?"+query.Encode(), nil)) + if recorder.Code != http.StatusBadRequest { + t.Fatalf("dual SDK marker status=%d body=%s", recorder.Code, recorder.Body.String()) + } +} + +func TestPublicTokenAuthenticationCannotExchangeWebCode(t *testing.T) { + f := newTelegramLoginHTTPFixture(t) + code := f.approveAndFinalize(t) + form := url.Values{ + "grant_type": {"authorization_code"}, "client_id": {f.credentials.Client.ClientID}, + "code": {code}, "redirect_uri": {f.redirectURI}, "code_verifier": {f.verifier}, + } + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPost, "/token", strings.NewReader(form.Encode())) + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + f.handler.ServeHTTP(recorder, request) + if recorder.Code != http.StatusUnauthorized || !strings.Contains(recorder.Body.String(), "invalid_client") { + t.Fatalf("public web exchange status=%d body=%s", recorder.Code, recorder.Body.String()) + } + + // Authentication failure happens before the one-time consume, so the same + // code remains usable by the confidential web client. + recorder = httptest.NewRecorder() + request = httptest.NewRequest(http.MethodPost, "/token", strings.NewReader(form.Encode())) + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + request.SetBasicAuth(f.credentials.Client.ClientID, f.credentials.Secret) + f.handler.ServeHTTP(recorder, request) + if recorder.Code != http.StatusOK { + t.Fatalf("authenticated retry status=%d body=%s", recorder.Code, recorder.Body.String()) + } +} + +func TestConcurrentTokenExchangeHasOneSuccess(t *testing.T) { + f := newTelegramLoginHTTPFixture(t) + code := f.approveAndFinalize(t) + form := url.Values{ + "grant_type": {"authorization_code"}, "code": {code}, "redirect_uri": {f.redirectURI}, + "client_id": {f.credentials.Client.ClientID}, "code_verifier": {f.verifier}, + }.Encode() + const workers = 8 + statuses := make(chan int, workers) + var wg sync.WaitGroup + for range workers { + wg.Add(1) + go func() { + defer wg.Done() + recorder := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/token", strings.NewReader(form)) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.SetBasicAuth(f.credentials.Client.ClientID, f.credentials.Secret) + f.handler.ServeHTTP(recorder, req) + statuses <- recorder.Code + }() + } + wg.Wait() + close(statuses) + success := 0 + for status := range statuses { + if status == http.StatusOK { + success++ + } else if status != http.StatusBadRequest { + t.Fatalf("unexpected concurrent exchange status %d", status) + } + } + if success != 1 { + t.Fatalf("successful exchanges=%d, want 1", success) + } +} + +func TestTokenEndpointSupportsBodySecretAndRejectsMixedAuthentication(t *testing.T) { + f := newTelegramLoginHTTPFixture(t) + code := f.approveAndFinalize(t) + form := url.Values{ + "grant_type": {"authorization_code"}, "client_id": {f.credentials.Client.ClientID}, + "client_secret": {f.credentials.Secret}, "code": {code}, "redirect_uri": {f.redirectURI}, + "code_verifier": {f.verifier}, + } + recorder := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/token", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + f.handler.ServeHTTP(recorder, req) + if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), `"id_token"`) { + t.Fatalf("client_secret_post status=%d body=%s", recorder.Code, recorder.Body.String()) + } + + secondCode := f.approveAndFinalize(t) + form.Set("code", secondCode) + recorder = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodPost, "/token", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.SetBasicAuth(f.credentials.Client.ClientID, f.credentials.Secret) + f.handler.ServeHTTP(recorder, req) + if recorder.Code != http.StatusBadRequest || !strings.Contains(recorder.Body.String(), "invalid_request") { + t.Fatalf("mixed authentication status=%d body=%s", recorder.Code, recorder.Body.String()) + } + + recorder = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodPost, "/token", strings.NewReader(`{}`)) + req.Header.Set("Content-Type", "application/json") + f.handler.ServeHTTP(recorder, req) + _, _ = io.Copy(io.Discard, recorder.Result().Body) + if recorder.Code != http.StatusUnsupportedMediaType { + t.Fatalf("JSON token content status=%d body=%s", recorder.Code, recorder.Body.String()) + } +} + +func TestHTTPRateLimitFailsBeforeAuthorizationCreation(t *testing.T) { + f := newTelegramLoginHTTPFixture(t) + f.handler.limiter = telegramLoginHTTPDenyLimiter{} + recorder := httptest.NewRecorder() + f.handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/auth", nil)) + if recorder.Code != http.StatusTooManyRequests || recorder.Header().Get("Retry-After") != "17" { + t.Fatalf("rate limit status=%d headers=%v body=%s", recorder.Code, recorder.Header(), recorder.Body.String()) + } +} + +func TestJavaScriptPostMessageFlowReturnsStableDirectIDToken(t *testing.T) { + f := newTelegramLoginHTTPFixture(t) + query := url.Values{ + "client_id": {f.credentials.Client.ClientID}, "redirect_uri": {"https://rp.example/"}, + "response_type": {"post_message"}, "scope": {"openid profile phone"}, + "nonce": {"js-nonce"}, + } + recorder := httptest.NewRecorder() + f.handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/auth?"+query.Encode(), nil)) + if recorder.Code != http.StatusOK { + t.Fatalf("JS authorize status=%d body=%s", recorder.Code, recorder.Body.String()) + } + body := recorder.Body.String() + tokenMatch := regexp.MustCompile(`const token=("[^"]+")`).FindStringSubmatch(body) + deepLinkMatch := regexp.MustCompile(`href="([^"]+)"`).FindStringSubmatch(body) + if len(tokenMatch) != 2 || len(deepLinkMatch) != 2 { + t.Fatalf("JS authorize artifacts missing: %s", body) + } + var browserToken string + if err := json.Unmarshal([]byte(tokenMatch[1]), &browserToken); err != nil { + t.Fatal(err) + } + deepLink := html.UnescapeString(deepLinkMatch[1]) + pending, err := f.service.RequestByDeepLink(context.Background(), deepLink) + if err != nil { + t.Fatal(err) + } + if pending.Source != domain.TelegramLoginRequestJavaScript || pending.Origin != "https://rp.example" || pending.CodeChallenge != "" { + t.Fatalf("official JavaScript request = %#v", pending) + } + *f.now = f.now.Add(time.Second) + if _, _, err := f.service.Approve(context.Background(), deepLink, domain.TelegramLoginIdentitySnapshot{ + UserID: 42, Name: "Alice Example", GivenName: "Alice", PhoneNumber: "15551234567", + }, false, true, pending.MatchCode); err != nil { + t.Fatal(err) + } + poll := func() map[string]string { + t.Helper() + statusRecorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPost, "/auth/status", strings.NewReader(url.Values{"browser_token": {browserToken}}.Encode())) + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + f.handler.ServeHTTP(statusRecorder, request) + if statusRecorder.Code != http.StatusOK { + t.Fatalf("JS status=%d body=%s", statusRecorder.Code, statusRecorder.Body.String()) + } + var result map[string]string + if err := json.Unmarshal(statusRecorder.Body.Bytes(), &result); err != nil { + t.Fatal(err) + } + return result + } + first, second := poll(), poll() + if first["status"] != "approved" || first["id_token"] == "" || second["id_token"] != first["id_token"] { + t.Fatalf("direct token first=%v second=%v", first, second) + } + web, err := f.service.ListWebAuthorizations(context.Background(), 42) + if err != nil || len(web) != 1 { + t.Fatalf("direct web authorization=%#v err=%v", web, err) + } + if err := f.service.RevokeWebAuthorization(context.Background(), 42, web[0].Hash); err != nil { + t.Fatal(err) + } + revokedRecorder := httptest.NewRecorder() + revokedRequest := httptest.NewRequest(http.MethodPost, "/auth/status", strings.NewReader(url.Values{"browser_token": {browserToken}}.Encode())) + revokedRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded") + f.handler.ServeHTTP(revokedRecorder, revokedRequest) + if revokedRecorder.Code != http.StatusInternalServerError || !strings.Contains(revokedRecorder.Body.String(), "server_error") { + t.Fatalf("revoked direct delivery status=%d body=%s", revokedRecorder.Code, revokedRecorder.Body.String()) + } + + exchangeForm := url.Values{ + "grant_type": {"authorization_code"}, "code": {first["id_token"]}, "redirect_uri": {"https://rp.example/"}, + "client_id": {f.credentials.Client.ClientID}, "code_verifier": {f.verifier}, + } + exchangeRecorder := httptest.NewRecorder() + exchangeRequest := httptest.NewRequest(http.MethodPost, "/token", strings.NewReader(exchangeForm.Encode())) + exchangeRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded") + exchangeRequest.SetBasicAuth(f.credentials.Client.ClientID, f.credentials.Secret) + f.handler.ServeHTTP(exchangeRecorder, exchangeRequest) + if exchangeRecorder.Code != http.StatusBadRequest || !strings.Contains(exchangeRecorder.Body.String(), "invalid_grant") { + t.Fatalf("direct token exchange status=%d body=%s", exchangeRecorder.Code, exchangeRecorder.Body.String()) + } +} + +func TestMiniAppOfficialInAppFlowIsOriginBoundAndOneTime(t *testing.T) { + f := newTelegramLoginHTTPFixture(t) + const origin = "https://rp.example" + query := url.Values{ + "client_id": {f.credentials.Client.ClientID}, "scope": {"openid profile phone telegram:bot_access"}, + "origin": {origin}, "response_type": {"id_token"}, + } + create := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "/inapp?"+query.Encode(), nil) + request.Header.Set("Origin", origin) + f.handler.ServeHTTP(create, request) + if create.Code != http.StatusOK || create.Header().Get("Access-Control-Allow-Origin") != origin || create.Header().Get("Vary") != "Origin" { + t.Fatalf("in-app create status=%d headers=%v body=%s", create.Code, create.Header(), create.Body.String()) + } + var created struct { + URL string `json:"url"` + } + if err := json.Unmarshal(create.Body.Bytes(), &created); err != nil || created.URL == "" { + t.Fatalf("in-app create response=%+v err=%v", created, err) + } + pending, err := f.service.RequestByDeepLinkForOrigin(context.Background(), created.URL, origin) + if err != nil { + t.Fatal(err) + } + if pending.Source != domain.TelegramLoginRequestMiniApp || pending.InAppOrigin != origin || pending.ResponseType != "post_message" { + t.Fatalf("in-app request=%#v", pending) + } + *f.now = f.now.Add(time.Second) + if _, _, err := f.service.Approve(context.Background(), created.URL, domain.TelegramLoginIdentitySnapshot{ + UserID: 42, Name: "Alice Example", GivenName: "Alice", PhoneNumber: "15551234567", + }, true, true, pending.MatchCode); err != nil { + t.Fatal(err) + } + resultURL, err := f.service.FinalizeInAppRedirectByDeepLink(context.Background(), created.URL) + if err != nil { + t.Fatal(err) + } + parsed, err := url.Parse(resultURL) + if err != nil || parsed.Scheme+"://"+parsed.Host != "https://oauth.telesrv.test" || parsed.Path != "/inapp" || parsed.Query().Get("token") == "" { + t.Fatalf("in-app result URL=%q err=%v", resultURL, err) + } + token := parsed.Query().Get("token") + + wrongOrigin := httptest.NewRecorder() + wrongRequest := httptest.NewRequest(http.MethodGet, "/inapp?code="+url.QueryEscape(token), nil) + wrongRequest.Header.Set("Origin", "https://attacker.example") + f.handler.ServeHTTP(wrongOrigin, wrongRequest) + if wrongOrigin.Code != http.StatusBadRequest { + t.Fatalf("wrong-origin exchange status=%d body=%s", wrongOrigin.Code, wrongOrigin.Body.String()) + } + + const workers = 8 + statuses := make(chan int, workers) + results := make(chan string, workers) + var wg sync.WaitGroup + for range workers { + wg.Add(1) + go func() { + defer wg.Done() + recorder := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/inapp?code="+url.QueryEscape(token), nil) + req.Header.Set("Origin", origin) + f.handler.ServeHTTP(recorder, req) + statuses <- recorder.Code + results <- recorder.Body.String() + }() + } + wg.Wait() + close(statuses) + close(results) + successes := 0 + responses := make([]string, 0, workers) + for status := range statuses { + if status == http.StatusOK { + successes++ + } else if status != http.StatusBadRequest { + t.Fatalf("unexpected in-app exchange status=%d", status) + } + } + for response := range results { + responses = append(responses, response) + } + if successes != 1 { + t.Fatalf("in-app exchange successes=%d, want 1; responses=%v", successes, responses) + } +} + +func TestTelegramLoginJavaScriptIsCacheableAndConditional(t *testing.T) { + f := newTelegramLoginHTTPFixture(t) + first := httptest.NewRecorder() + f.handler.ServeHTTP(first, httptest.NewRequest(http.MethodGet, "/js/telegram-login.js", nil)) + if first.Code != http.StatusOK || first.Header().Get("ETag") == "" || + !strings.Contains(first.Body.String(), "Telegram.Login") || + !strings.Contains(first.Body.String(), "auth_result") || + !strings.Contains(first.Body.String(), "oauth_supported") || + !strings.Contains(first.Body.String(), "/inapp?") || + !strings.Contains(first.Body.String(), "data-client-id") { + t.Fatalf("SDK status=%d headers=%v body=%s", first.Code, first.Header(), first.Body.String()) + } + second := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "/js/telegram-login.js", nil) + request.Header.Set("If-None-Match", first.Header().Get("ETag")) + f.handler.ServeHTTP(second, request) + if second.Code != http.StatusNotModified { + t.Fatalf("conditional SDK status=%d", second.Code) + } +} diff --git a/internal/telegramloginhttp/sdk.go b/internal/telegramloginhttp/sdk.go new file mode 100644 index 00000000..cadd2c00 --- /dev/null +++ b/internal/telegramloginhttp/sdk.go @@ -0,0 +1,90 @@ +package telegramloginhttp + +import ( + "crypto/sha256" + "encoding/base64" + "net/http" +) + +// telegramLoginJavaScript intentionally follows the public Telegram Login +// SDK contract (programmatic API, data-* auto-init, popup auth_result events, +// and the Mini App oauth_* bridge). It derives the provider from its own +// script origin so the same file works on a self-hosted issuer. +const telegramLoginJavaScript = `(function(global){ +'use strict'; +var current=document.currentScript; +if(!current){throw new Error('Telegram Login SDK must be loaded by a script element');} +var provider=new URL(current.src,document.baseURI).origin; +var saved=null,active=null,inApp=false,inAppPending=false; +function callback(cb,value){if(typeof cb==='function'){try{cb(value);}catch(error){setTimeout(function(){throw error;},0);}}} +function decode(token){try{var part=token.split('.')[1].replace(/-/g,'+').replace(/_/g,'/');while(part.length%4){part+='=';}return JSON.parse(decodeURIComponent(Array.from(atob(part),function(c){return '%'+c.charCodeAt(0).toString(16).padStart(2,'0');}).join('')));}catch(_){return null;}} +function build(data){if(data&&data.error){return {error:String(data.error)};}var token=data&&data.result;if(typeof token!=='string'||!token){return {error:'missing id_token'};}var user=decode(token);return user?{id_token:token,user:user}:{error:'malformed id_token'};} +function normalize(options){ + if(!options||!/^[0-9]{1,64}$/.test(String(options.client_id||''))){throw new Error('Telegram.Login client_id is required');} + var scopes=['openid'],input=options.scope; + if(input===undefined||input===null||input===''){scopes.push('profile');input=options.request_access||[];} + if(typeof input==='string'){input=input.trim()?input.trim().split(/\s+/):[];} + if(!Array.isArray(input)){throw new Error('Telegram.Login scope must be an array or string');} + var allowed={profile:'profile',phone:'phone',write:'telegram:bot_access','telegram:bot_access':'telegram:bot_access'}; + input.forEach(function(value){var mapped=allowed[value];if(!mapped){throw new Error('Telegram.Login scope is invalid');}if(scopes.indexOf(mapped)<0){scopes.push(mapped);}}); + return {client_id:String(options.client_id),scope:scopes.join(' '),nonce:String(options.nonce||'').slice(0,1024),lang:String(options.lang||'').slice(0,16)}; +} +function randomURL(bytes){var data=new Uint8Array(bytes);crypto.getRandomValues(data);var raw='';data.forEach(function(value){raw+=String.fromCharCode(value);});return btoa(raw).replace(/\+/g,'-').replace(/\//g,'_').replace(/=+$/,'');} +function finish(flow,result){if(active!==flow){return;}active=null;if(flow.timer){clearInterval(flow.timer);}if(flow.listener){global.removeEventListener('message',flow.listener);}callback(flow.callback,result);} +function sendEvent(type,data){if(global.TelegramWebviewProxy&&typeof global.TelegramWebviewProxy.postEvent==='function'){global.TelegramWebviewProxy.postEvent(type,JSON.stringify(data||{}));}} +async function receiveEvent(type,data){ + if(type==='oauth_supported'){inApp=true;return;} + if(type==='oauth_result_failed'){if(active){finish(active,{error:'access_denied'});}return;} + if(type!=='oauth_result_confirmed'||!active||!data||!data.result_url){return;} + try{var resultURL=new URL(data.result_url);if(resultURL.origin!==provider||resultURL.pathname!=='/inapp'){throw new Error('invalid in-app result URL');}var token=resultURL.searchParams.get('token');if(!token){throw new Error('missing in-app token');} + var response=await fetch(provider+'/inapp?code='+encodeURIComponent(token),{credentials:'omit',cache:'no-store'}),result=await response.json(); + if(!response.ok){throw new Error(result.error||'in-app exchange failed');}finish(active,build(result)); + }catch(error){finish(active,{error:error.message||'in_app_failed'});} +} +function begin(options,cb,isNormalized){ + var normalized;try{normalized=isNormalized?options:normalize(options);}catch(error){callback(cb,{error:error.message});return null;} + if(active){callback(cb,{error:'login_in_progress'});return null;} + var flow={popup:null,callback:cb,listener:null,timer:null};active=flow; + if(inApp){ + if(inAppPending){finish(flow,{error:'login_in_progress'});return null;}inAppPending=true; + var inAppParams=new URLSearchParams({scope:normalized.scope,origin:global.location.origin,client_id:normalized.client_id,response_type:'id_token'}); + fetch(provider+'/inapp?'+inAppParams.toString(),{credentials:'omit',cache:'no-store'}).then(function(response){return response.json().then(function(body){if(!response.ok){throw new Error(body.error||'in-app request failed');}return body;});}).then(function(body){if(!body.url){throw new Error('missing OAuth deep link');}sendEvent('oauth_request',{url:body.url});}).catch(function(error){finish(flow,{error:error.message||'in_app_failed'});}).finally(function(){setTimeout(function(){inAppPending=false;},600);}); + return null; + } + var popup=global.open('about:blank','telegram-login-'+randomURL(8),'popup,width=550,height=650,resizable=yes,scrollbars=yes'); + if(!popup){finish(flow,{error:'popup_blocked'});return null;}flow.popup=popup; + flow.listener=function(event){if(event.origin!==provider||event.source!==popup){return;}var data=event.data;try{if(typeof data==='string'){data=JSON.parse(data);}}catch(_){return;}if(!data||data.event!=='auth_result'){return;}finish(flow,build(data));}; + global.addEventListener('message',flow.listener);flow.timer=setInterval(function(){if(popup.closed){finish(flow,{error:'popup_closed'});}},500); + try{var params=new URLSearchParams({client_id:normalized.client_id,redirect_uri:global.location.origin+global.location.pathname,response_type:'post_message',scope:normalized.scope});if(normalized.nonce){params.set('nonce',normalized.nonce);}if(normalized.lang){params.set('lang',normalized.lang);}popup.location.replace(provider+'/auth?'+params.toString());} + catch(error){try{popup.close();}catch(_){}finish(flow,{error:error.message||'login_failed'});} + return popup; +} +var api={ + init:function(options,cb){saved={options:normalize(options),callback:cb};return api;}, + open:function(cb){if(!saved){callback(cb,{error:'not_initialized'});return null;}return begin(saved.options,cb||saved.callback,true);}, + auth:function(options,cb){return begin(options,cb,false);}, + close:function(){if(active&&active.popup){try{active.popup.close();}catch(_){}}if(active){finish(active,{error:'popup_closed'});}} +}; +global.Telegram=global.Telegram||{};global.Telegram.Login=api; +global.Telegram.WebView=global.Telegram.WebView||{};global.Telegram.WebView.receiveEvent=receiveEvent; +global.Telegram.TelegramGameProxy=global.Telegram.TelegramGameProxy||{};global.Telegram.TelegramGameProxy.receiveEvent=receiveEvent; +if(global.TelegramWebviewProxy){sendEvent('oauth_request',{});} +document.addEventListener('click',function(event){var node=event.target;while(node&&node!==document){if(node.classList&&node.classList.contains('tg-auth-button')){api.open();return;}node=node.parentNode;}}); +function resolveCallback(source){var match=/^([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)\s*\(\s*data\s*\)\s*;?$/.exec(source||'');if(!match){return null;}return function(data){var target=global,parts=match[1].split('.');for(var i=0;i