456 lines
20 KiB
Python
456 lines
20 KiB
Python
"""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 = (
|
|
"<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
|