feat: sync Telegram Login OIDC provider

This commit is contained in:
A 2026-07-21 15:46:24 +08:00
parent 30774f8c39
commit ebead9e98c
63 changed files with 11374 additions and 37 deletions

View file

@ -159,6 +159,24 @@ TELESRV_STICKER_SEED_DIR=data/sticker-seed
# through nginx; public canonical URLs use TELESRV_PUBLIC_BASE_URL.
TELESRV_PUBLIC_LINK_WEB_ADDR=127.0.0.1:2401
# Self-hosted Telegram Login / OpenID Connect. The provider is mounted on the
# public-link listener above. Keep disabled until all three local key files
# have been generated with `go run ./cmd/telegramloginkeygen -mode init`.
TELESRV_TELEGRAM_LOGIN_ENABLE=false
TELESRV_TELEGRAM_LOGIN_ISSUER=https://telesrv.net
TELESRV_TELEGRAM_LOGIN_ALLOW_LOOPBACK_HTTP=false
TELESRV_TELEGRAM_LOGIN_SIGNING_KEYS_FILE=data/telegram-login/signing-keys.json
TELESRV_TELEGRAM_LOGIN_CODE_KEYS_FILE=data/telegram-login/code-keys.json
TELESRV_TELEGRAM_LOGIN_SECRET_PEPPER_FILE=data/telegram-login/client-secret-pepper
TELESRV_TELEGRAM_LOGIN_REQUEST_TTL=5m
TELESRV_TELEGRAM_LOGIN_CODE_TTL=2m
TELESRV_TELEGRAM_LOGIN_ID_TOKEN_TTL=1h
# Trust only the loopback nginx hop in the documented single-host deployment.
TELESRV_TELEGRAM_LOGIN_TRUSTED_PROXY_CIDRS=127.0.0.1/32,::1/128
TELESRV_TELEGRAM_LOGIN_RETENTION=168h
TELESRV_TELEGRAM_LOGIN_SWEEP_INTERVAL=5m
TELESRV_TELEGRAM_LOGIN_SWEEP_BATCH=500
# AI compose for TDesktop/Android input box rewrite/polish.
# The local provider is deterministic and does not call external services.
TELESRV_AI_ENABLED=true

View file

@ -1,4 +1,4 @@
# Bedolaga formatted-text demo
# Bedolaga formatted-text + Telegram Login demo
这个 demo 复刻 Bedolaga 的 Bot 工厂关键配置:
@ -67,3 +67,61 @@ $env:TELESRV_BOT_API_SERVER = "http://127.0.0.1:8081"
`--base-url` 只接受 API server 根地址,不要追加 `/bot`。脚本不会打印 token也不会
把 token 写入文件。
## Telegram Login 全链路
同一个 demo 还提供 `/logindemo`,覆盖三个互相独立的公开契约:
1. Bot API `login_url` 按钮 → TDesktop/Android 的
`messages.requestUrlAuth/acceptUrlAuth` → legacy HMAC 回调;
2. telesrv 本地 `/telegram-login.js` → popup `postMessage` → JWKS 验签;
3. 服务端 Authorization Code + PKCE S256 → `/token` Basic Client Secret →
JWKS 验签和 `issuer/audience/nonce/subject` 复核。
先在 telesrv 的 @BotFather 中对目标 bot 运行 `/setlogin`。选择 bot 后逐条登记 demo
的精确 origin 和 callback本机示例
```text
add origin http://127.0.0.1:3000
add redirect http://127.0.0.1:3000/oauth/callback
enable
```
`/setlogin` 首次创建 client 时只展示一次 OIDC Client Secret不要写进仓库。可用
`/logininfo` 查看 Client ID 和登记结果,或用 `/resetloginsecret` 轮换 secret。
loopback HTTP 仅应配合 telesrv 的显式开发开关使用testserver/生产必须换成精确
HTTPS origin。
把一次性 secret 和 Client ID 放入进程环境,再启动:
```powershell
$env:TELESRV_BOT_LOGIN_DEMO = "1"
$env:TELESRV_BOT_LOGIN_ISSUER = "http://127.0.0.1:2401"
$env:TELESRV_BOT_LOGIN_CLIENT_ID = "<numeric bot user id>"
$env:TELESRV_BOT_LOGIN_CLIENT_SECRET = "<one-time OIDC client secret>"
$env:TELESRV_BOT_LOGIN_PUBLIC_URL = "http://127.0.0.1:3000"
$env:TELESRV_BOT_LOGIN_LISTEN = "127.0.0.1:3000"
& "$env:TEMP\telesrv-bedolaga-demo-venv\Scripts\python.exe" `
.\cmd\bots\bedolagaformat\demo.py --drop-pending --login-demo
```
向 bot 发送 `/logindemo`。第一颗按钮必须出现 Telegram 客户端原生授权确认框,批准
后网页显示 `login_url HMAC verified`;第二颗按钮打开测试页,可分别运行 JS SDK popup
和 Authorization Code + PKCE。页面只展示验签后的 claims不展示 access token 或
Client Secret。省略 `TELESRV_BOT_LOGIN_CLIENT_SECRET` 时仍可验证 JS popup但服务端
code flow 会明确禁用。
demo 的 flow/state/nonce 只保存在单进程内存中,带 10 分钟过期和 256 条上限,专用于
本地与 testserver 端到端验证,不是生产 relying-party 实现。官方 iOS/Android SDK
目前把 `https://oauth.telegram.org` 写死;验证自建 issuer 时需使用项目记录的最小
base-URL patch 或等价测试构建,不能把官方生产 SDK 未修改的结果误判为自建服务结果。
测试命令:
```powershell
& "$env:TEMP\telesrv-bedolaga-demo-venv\Scripts\python.exe" `
.\cmd\bots\bedolagaformat\test_demo.py -v
& "$env:TEMP\telesrv-bedolaga-demo-venv\Scripts\python.exe" `
.\cmd\bots\bedolagaformat\test_login_demo.py -v
```

View file

@ -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"<b>{marker} Telegram Login</b>\n"
"The first button validates Bot API <code>login_url</code>; "
"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(
"<b>Telegram Login demo is disabled.</b> Start this program with "
"<code>--login-demo</code>."
)
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()

View file

@ -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 = (
"<p class=\"ok\">login_url HMAC verified for user "
+ html.escape(identity["id"])
+ ".</p>"
)
except ValueError:
legacy_result = '<p class="error">login_url HMAC verification failed.</p>'
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 = '<a class="button" href="/login/code">Authorization Code + PKCE</a>'
if not self.config.code_flow_enabled:
code_link = '<span class="disabled">Code flow disabled: configure client secret.</span>'
script_config = json.dumps(
{"clientID": self.config.client_id, "flowID": flow_id, "nonce": nonce},
separators=(",", ":"),
).replace("<", "\\u003c")
body = f"""<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"><title>Bedolaga Telegram Login Demo</title>
<style>body{{font:16px/1.5 system-ui,sans-serif;max-width:720px;margin:8vh auto;padding:24px;background:#17212b;color:#fff}}.card{{background:#202b36;padding:28px;border-radius:18px}}button,.button{{display:inline-block;border:0;border-radius:10px;padding:12px 18px;margin:8px 8px 8px 0;background:#2aabee;color:#fff;text-decoration:none;font-weight:700;cursor:pointer}}.disabled{{display:block;color:#a9b5c1;margin:12px 0}}pre{{white-space:pre-wrap;overflow-wrap:anywhere;background:#111b24;padding:14px;border-radius:8px}}.ok{{color:#73d18a}}.error{{color:#ff8d8d}}</style></head>
<body><main class="card"><h1>Bedolaga Telegram Login Demo</h1>{legacy_result}
<p>This page verifies the self-hosted JavaScript SDK and the standard server-side OIDC flow.</p>
<button id="popup">JavaScript SDK popup</button>{code_link}<pre id="result">Ready.</pre></main>
<script src="{html.escape(sdk_url, quote=True)}"></script>
<script nonce="{csp_nonce}">const cfg={script_config},out=document.getElementById('result');document.getElementById('popup').addEventListener('click',()=>{{out.textContent='Waiting for Telegram approval…';Telegram.Login.auth({{client_id:cfg.clientID,request_access:['phone','write'],nonce:cfg.nonce}},async result=>{{if(result.error){{out.textContent='Login failed: '+result.error;return}}try{{const response=await fetch('/verify-popup',{{method:'POST',headers:{{'content-type':'application/json'}},body:JSON.stringify({{flow_id:cfg.flowID,id_token:result.id_token,in_app:Boolean(window.TelegramWebviewProxy)}})}}),verified=await response.json();if(!response.ok)throw new Error(verified.error||'verification_failed');out.textContent=JSON.stringify(verified.claims,null,2)}}catch(error){{out.textContent='Verification failed: '+error.message}}}})}});</script></body></html>"""
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 = (
'<!doctype html><html lang="en"><head><meta charset="utf-8"><title>'
+ html.escape(title)
+ "</title></head><body><h1 class=\""
+ css_class
+ "\">"
+ html.escape(title)
+ "</h1><pre>"
+ html.escape(json.dumps(payload, indent=2, ensure_ascii=False))
+ '</pre><p><a href="/">Run another flow</a></p></body></html>'
)
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

View file

@ -1 +1,2 @@
aiogram==3.30.0
PyJWT[crypto]==2.10.1

View file

@ -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()

View file

@ -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()

View file

@ -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
}

View file

@ -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)
}
}

View file

@ -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()
}
}
}

View file

@ -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;

View file

@ -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;

10
go.mod
View file

@ -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

20
go.sum
View file

@ -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=

View file

@ -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 并设置 joingroupsjoin=true或 privacyjoin=false
func (s *Service) applyToggle(ctx context.Context, botID int64, text string, join bool) (botReply, error) {
var on bool

View file

@ -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)
}
}

View file

@ -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) {

View file

@ -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
}

View file

@ -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)
}

View file

@ -0,0 +1,5 @@
//go:build !jwx_es256k
package telegramlogin
const telegramLoginES256KEnabled = false

View file

@ -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")
}
}

View file

@ -0,0 +1,5 @@
//go:build jwx_es256k
package telegramlogin
const telegramLoginES256KEnabled = true

View file

@ -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)
}
}

View file

@ -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")
}
}

View file

@ -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))
}

View file

@ -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")
}
}

View file

@ -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
}

File diff suppressed because it is too large Load diff

View file

@ -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)
}
}

View file

@ -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
}

View file

@ -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)
}
}

View file

@ -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
}

View file

@ -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:

View file

@ -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) {

View file

@ -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 将 /<username>、/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")

View file

@ -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")

View file

@ -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 {

View file

@ -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},

View file

@ -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
}

View file

@ -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")
}
}

View file

@ -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.

View file

@ -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

View file

@ -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()
}

View file

@ -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 {

View file

@ -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 {

View file

@ -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

View file

@ -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,

View file

@ -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()),

View file

@ -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)

View file

@ -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
}

View file

@ -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)
}

View file

@ -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)))
}
}

View file

@ -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"},
},

View file

@ -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
}

View file

@ -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)
}
}

View file

@ -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)
}
}

File diff suppressed because it is too large Load diff

View file

@ -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")
}
}

View file

@ -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)
}

View file

@ -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(`<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Log in with {{.AppName}}</title><style>
:root{color-scheme:light dark}body{font:16px/1.45 system-ui,sans-serif;margin:0;background:#17212b;color:#fff}.card{max-width:460px;margin:10vh auto;padding:28px;border-radius:18px;background:#202b36;box-shadow:0 16px 48px #0006}h1{margin:.1em 0 .5em}.button{display:block;text-align:center;margin:24px 0;padding:13px 18px;border-radius:11px;background:#2aabee;color:#fff;text-decoration:none;font-weight:700}.match{text-align:center;margin:22px 0;padding:18px;border-radius:14px;background:#17212b}.match p{margin:0 0 8px}.match-code{font-size:44px;line-height:1.2}.muted{color:#a9b5c1;font-size:14px}.error{color:#ff8d8d}</style></head>
<body><main class="card"><h1>Log in with {{.AppName}}</h1><p>Open the {{.AppName}} app and approve this request. Keep this page open.</p><a class="button" href="{{.DeepLink}}">Open {{.AppName}}</a>{{if .MatchCode}}<section class="match" aria-labelledby="match-title"><p id="match-title">When prompted, select this emoji in {{.AppName}}:</p><div id="match-code" class="match-code" role="img" aria-label="Matching emoji">{{.MatchCode}}</div></section>{{end}}<p id="status" class="muted">Waiting for approval</p><p class="muted">This request expires at {{.ExpiresAt}}.</p></main>
<script nonce="{{.CSPNonce}}">const token={{.BrowserToken}},responseType={{.ResponseType}},targetOrigin={{.TargetOrigin}};const statusNode=document.getElementById('status');function deliver(data){if(!window.opener||!targetOrigin)throw new Error('missing_opener');const payload=data.id_token?{event:'auth_result',result:data.id_token}:{event:'auth_result',error:data.error||data.status};window.opener.postMessage(payload,targetOrigin);window.close()}async function poll(){try{const body=new URLSearchParams({browser_token:token});const response=await fetch('/auth/status',{method:'POST',headers:{'content-type':'application/x-www-form-urlencoded'},body,cache:'no-store'});const data=await response.json();if(!response.ok){throw new Error(data.error||'request_failed')}if(data.status==='pending'){setTimeout(poll,1000);return}if(responseType==='post_message'){deliver(data);return}if(data.redirect_url){location.replace(data.redirect_url);return}throw new Error('invalid_response')}catch(error){statusNode.className='error';statusNode.textContent='Login status unavailable. Please restart the login flow.'}}poll();</script></body></html>`))
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(`<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Telegram Login</title></head><body><p>Login could not be started.</p><script nonce="{{.Nonce}}">if(window.opener){window.opener.postMessage({event:'auth_result',error:{{.Error}}},{{.Origin}});window.close();}</script></body></html>`))
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, "<22>"))
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)
}

View file

@ -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();</script>") {
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)
}
}

View file

@ -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<parts.length-1;i++){target=target&&target[parts[i]];}var fn=target&&target[parts[parts.length-1]];if(typeof fn==='function'){fn.call(target,data);}};}
function autoInit(){var client=current.getAttribute('data-client-id');if(!client){return;}var options={client_id:client},access=current.getAttribute('data-request-access'),lang=current.getAttribute('data-lang');if(access){options.request_access=access.trim().split(/\s+/);}if(lang){options.lang=lang;}api.init(options,resolveCallback(current.getAttribute('data-onauth')));}
if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',autoInit);}else{autoInit();}
})(window);`
func (h *Handler) loginJavaScript(w http.ResponseWriter, r *http.Request) {
sum := sha256.Sum256([]byte(telegramLoginJavaScript))
etag := `"` + base64.RawURLEncoding.EncodeToString(sum[:]) + `"`
w.Header().Set("Content-Type", "text/javascript; charset=utf-8")
w.Header().Set("Cache-Control", "public, max-age=3600, must-revalidate")
w.Header().Set("ETag", etag)
if r.Header.Get("If-None-Match") == etag {
w.WriteHeader(http.StatusNotModified)
return
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(telegramLoginJavaScript))
}

View file

@ -33,6 +33,10 @@ type Config struct {
Photos ProfilePhotoResolver
UniqueGifts UniqueStarGiftResolver
GiftWithdrawals StarGiftWithdrawalResolver
// TelegramLogin is the optional OIDC/Login HTTP adapter. Public Web owns
// the listener so discovery/auth/token and public links share the exact
// externally registered origin behind one reverse proxy.
TelegramLogin http.Handler
}
type StickerSetResolver interface {
@ -166,6 +170,17 @@ func newHandler(cfg Config, logger *zap.Logger) (http.Handler, error) {
mux.HandleFunc("GET /nft/{slug}/{$}", h.uniqueGift)
mux.HandleFunc("GET /gift-withdrawal/{requestID}", h.starGiftWithdrawal)
mux.HandleFunc("POST /gift-withdrawal/{requestID}", h.completeStarGiftWithdrawal)
if cfg.TelegramLogin != nil {
mux.Handle("GET /.well-known/openid-configuration", cfg.TelegramLogin)
mux.Handle("GET /.well-known/jwks.json", cfg.TelegramLogin)
mux.Handle("GET /auth", cfg.TelegramLogin)
mux.Handle("GET /crossapp", cfg.TelegramLogin)
mux.Handle("GET /inapp", cfg.TelegramLogin)
mux.Handle("POST /auth/status", cfg.TelegramLogin)
mux.Handle("POST /token", cfg.TelegramLogin)
mux.Handle("GET /telegram-login.js", cfg.TelegramLogin)
mux.Handle("GET /js/telegram-login.js", cfg.TelegramLogin)
}
mux.HandleFunc("GET /{username}", h.usernameLink)
mux.HandleFunc("GET /{username}/{$}", h.usernameLink)
return publicSecurityHeaders(mux), nil