Telegram-login JWT for the web console
D7 fixed: the RSA key is configured or generated once into signing_keys (migration 0008), so it survives a restart and every replica shares it - v1 generated one per gunicorn worker per start and published only the answering worker's in its JWKS. Also D-WL-2: v1's pop('hash') meant only the first of its five bot tokens could ever sign anyone in. auth_date enforcement is written but off by default (the WebHub renews by replaying the payload) - see docs/contracts/x_webhub_login.md. No QA scenario: the feature has no Telegram surface
- v1 scenarios
- —
- Ported
- —
- Green
- —
- Failing
- —
D7 fixed: the RSA key is configured or generated once into signing_keys (migration 0008), so it survives a restart and every replica shares it - v1 generated one per gunicorn worker per start and published only the answering worker's in its JWKS. Also D-WL-2: v1's pop('hash') meant only the first of its five bot tokens could ever sign anyone in. auth_date enforcement is written but off by default (the WebHub renews by replaying the payload) - see docs/contracts/x_webhub_login.md. No QA scenario: the feature has no Telegram surface
Server.py:25-52What it does
COOKIEBOT-WebHub signs a user in with Telegram's login widget and posts the
widget's payload to POST /login. cb-api checks the payload's HMAC against
every configured bot token, and answers with a 30-minute RS256 JWT whose sub
is the Telegram user id. /.well-known/jwks.json and
/.well-known/openid-configuration let a resource server verify that token
without calling back. GET / reports that the bot is online and how many
groups it is in.
Behaviour that must not change
- The response shapes.
{"status": "Token generated", "accessToken": …},400 {"error": "Missing data"},401 {"error": "Invalid bot token"}. The shipped client readsaccessTokenby name and branches on the codes. - The claim set —
exp,iat,kid,sub,iss— andkidappearing as a payload claim as well as a header, which is v1's own doing. kiddefaults tocookiebot-2025and the algorithm is RS256.- The discovery document's fields, unchanged from v1.
Four v1 behaviours are deliberately not preserved, all argued in
docs/contracts/x_webhub_login.md: the signing key now survives a restart and
is shared by every replica (D7); every configured bot's token can sign a user
in, where v1 could only ever match the first of its five; iat is floored so it
is never a second in the future; and /login's CORS is an allowlist rather than
*.
How to verify it
No Gherkin scenario — the feature has no Telegram surface.
packages/cb-api/tests/test_login_endpoints.py drives all four endpoints over
HTTP, and qa/integration/test_webhub_login.py is the D7 regression against a
real database: issue a token, drop the process cache, and verify it against the
JWKS the "restarted" process publishes.
By hand: python scripts/cb.py api, then
curl localhost:8000/.well-known/openid-configuration. A login needs a real
widget payload, so the quickest check is to point a local WebHub at it with
CB_WEBHUB_ALLOWED_ORIGINS=["http://localhost:3000"].
Two things to set before this goes live
CB_WEBHUB_ISSUER. Unset, the issuer is the request's own base URL — v1's behaviour, and behind a proxy that is theX-Forwarded-Hostheader, so a caller chooses theissof the tokens this service mints.CB_WEBHUB_AUTH_MAX_AGE_SECONDS, which is0— v1's replay window, which is forever. It ships off because the WebHub renews a session by re-posting the login payload it stored the first time, so any real value logs those sessions out. Closing it is a client change first.
OAuth2 token issuer for the Telegram Mini App
net-new: v1 had no Mini App. /oauth2/token with three grants (Telegram initData, the login widget, refresh_token), the same RS256 keys and JWKS /login already publishes, plus scopes, a short access-token life and rotating refresh tokens stored as hashes (migration 0010). A replayed refresh token revokes its whole family. /login is untouched and its scope-less token stays read-only
Architecture
How v2 is built — four services, Citus, the compiled hot path — and why each choice was made