Bedolaga Telegram Login Demo
{legacy_result}This page verifies the self-hosted JavaScript SDK and the standard server-side OIDC flow.
{code_link}Ready.
"""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 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") return f"{parsed.scheme}://{parsed.netloc}" def parse_listen(value: str) -> tuple[str, int]: parsed = urlsplit("//" + value.strip()) try: port = parsed.port except ValueError as exc: raise ValueError("login demo listen port is invalid") from exc if not parsed.hostname or port is None or not 1 <= port <= 65535: raise ValueError("login demo listen address must be host:port") return parsed.hostname, port def base64url(data: bytes) -> str: return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii") def generate_pkce() -> tuple[str, str]: verifier = base64url(secrets.token_bytes(32)) challenge = base64url(hashlib.sha256(verifier.encode("ascii")).digest()) return verifier, challenge def verify_legacy_login_query( query: dict[str, str], bot_token: str, *, now: int | None = None ) -> dict[str, str]: """Verify Telegram's legacy login_url data-check string. Only the documented signed identity fields participate. Query parameters already present on the relying-party URL are intentionally excluded. """ signed_names = ( "auth_date", "first_name", "id", "last_name", "photo_url", "username", ) supplied_hash = query.get("hash", "") if len(supplied_hash) != 64: raise ValueError("missing legacy login signature") values = {name: query[name] for name in signed_names if name in query} if not all(values.get(name) for name in ("auth_date", "first_name", "id")): raise ValueError("incomplete legacy login payload") try: auth_date = int(values["auth_date"]) user_id = int(values["id"]) except ValueError as exc: raise ValueError("invalid legacy login payload") from exc current = int(time.time()) if now is None else now if user_id <= 0 or auth_date > current + 30 or current - auth_date > LEGACY_AUTH_MAX_AGE_SECONDS: raise ValueError("expired legacy login payload") data_check = "\n".join(f"{name}={values[name]}" for name in sorted(values)) secret_key = hashlib.sha256(bot_token.encode("utf-8")).digest() actual = hmac.new(secret_key, data_check.encode("utf-8"), hashlib.sha256).hexdigest() if not hmac.compare_digest(actual, supplied_hash.lower()): raise ValueError("invalid legacy login signature") return values def _safe_claims(claims: dict[str, Any]) -> dict[str, Any]: allowed = ( "iss", "aud", "sub", "iat", "exp", "nonce", "id", "name", "given_name", "family_name", "preferred_username", "picture", "phone_number", "phone_number_verified", ) return {key: claims[key] for key in allowed if key in claims} class LoginDemoServer: def __init__(self, config: LoginDemoConfig, bot_token: str) -> None: self.config = config self.bot_token = bot_token self._flows: dict[str, PendingFlow] = {} self._flow_lock = asyncio.Lock() self._http: ClientSession | None = None self._runner: web.AppRunner | None = None async def start(self) -> None: timeout = ClientTimeout(total=10) self._http = ClientSession(timeout=timeout) app = web.Application(client_max_size=64 * 1024) app.add_routes( [ web.get("/", self.root), web.get("/login/code", self.start_code_flow), web.get("/oauth/callback", self.code_callback), web.post("/verify-popup", self.verify_popup), web.get("/healthz", self.health), ] ) self._runner = web.AppRunner(app, access_log=None) await self._runner.setup() site = web.TCPSite(self._runner, self.config.listen_host, self.config.listen_port) await site.start() LOG.info( "Telegram Login demo listening at %s (issuer=%s client_id=%s)", self.config.public_url, self.config.issuer, self.config.client_id, ) async def close(self) -> None: if self._runner is not None: await self._runner.cleanup() self._runner = None if self._http is not None: await self._http.close() self._http = None async def _put_flow(self, flow: PendingFlow) -> str: flow_id = secrets.token_urlsafe(24) now = time.time() async with self._flow_lock: self._flows = { key: value for key, value in self._flows.items() if value.expires_at > now } if len(self._flows) >= MAX_PENDING_FLOWS: oldest = min(self._flows, key=lambda key: self._flows[key].expires_at) del self._flows[oldest] self._flows[flow_id] = flow return flow_id async def _take_flow(self, flow_id: str, *, consume: bool) -> PendingFlow: async with self._flow_lock: flow = self._flows.get(flow_id) if flow is None or flow.expires_at <= time.time(): self._flows.pop(flow_id, None) raise ValueError("login flow is invalid or expired") if consume: del self._flows[flow_id] return flow @staticmethod def _headers(response: web.StreamResponse) -> None: response.headers["Cache-Control"] = "no-store" response.headers["Pragma"] = "no-cache" response.headers["X-Content-Type-Options"] = "nosniff" response.headers["Referrer-Policy"] = "no-referrer" async def health(self, _: web.Request) -> web.Response: response = web.json_response({"status": "ok"}) self._headers(response) return response async def root(self, request: web.Request) -> web.Response: legacy_result = "" if "hash" in request.query: try: signed_names = { "auth_date", "first_name", "id", "last_name", "photo_url", "username", "hash" } if any(len(request.query.getall(key, [])) != 1 for key in signed_names if key in request.query): raise ValueError("duplicate legacy login field") query = {key: request.query[key] for key in request.query} identity = verify_legacy_login_query(query, self.bot_token) legacy_result = ( "
login_url HMAC verified for user " + html.escape(identity["id"]) + ".
" ) except ValueError: legacy_result = 'login_url HMAC verification failed.
' nonce = secrets.token_urlsafe(24) flow_id = await self._put_flow(PendingFlow(nonce=nonce, expires_at=time.time() + FLOW_TTL_SECONDS)) sdk_url = self.config.issuer + "/js/telegram-login.js" csp_nonce = secrets.token_urlsafe(18) code_link = 'Authorization Code + PKCE' if not self.config.code_flow_enabled: code_link = 'Code flow disabled: configure client secret.' script_config = json.dumps( {"clientID": self.config.client_id, "flowID": flow_id, "nonce": nonce}, separators=(",", ":"), ).replace("<", "\\u003c") body = f"""This page verifies the self-hosted JavaScript SDK and the standard server-side OIDC flow.
{code_link}Ready.
"
+ html.escape(json.dumps(payload, indent=2, ensure_ascii=False))
+ ''
)
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