"
+$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}}:
{{.MatchCode}}
{{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 LoginLogin 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
Date: Tue, 21 Jul 2026 15:46:52 +0800
Subject: [PATCH 04/28] fix: sync Telegram Login mobile popup delivery
---
internal/telegramloginhttp/handler.go | 35 +++++++++++++++-
internal/telegramloginhttp/handler_test.go | 46 ++++++++++++++++++++--
internal/telegramloginhttp/sdk.go | 9 +++--
3 files changed, 81 insertions(+), 9 deletions(-)
diff --git a/internal/telegramloginhttp/handler.go b/internal/telegramloginhttp/handler.go
index 9130a5e8..b977b76d 100644
--- a/internal/telegramloginhttp/handler.go
+++ b/internal/telegramloginhttp/handler.go
@@ -166,9 +166,16 @@ 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}}:
{{.MatchCode}}
{{end}}Waiting for approval…
This request expires at {{.ExpiresAt}}.
-