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

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