mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
feat: Telegram V3 — Mini App cockpit (initData auth + /tg surface) (#554)
* feat(telegram): Mini App auth — initData validation mints the cloud-auth session cookie * feat(panel): /tg Mini App cockpit — approvals, inbox, read-only board, A2A chat * fix(telegram,panel): unconditional webapp-auth rate limit, future-dated initData rejection, anchored /tg matcher * docs(map,rag): Telegram Mini App auth route, initData validator, (tg) surface --------- Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
"""Telegram Mini App sign-in route coverage.
|
||||
|
||||
Covers the conditional mount (both `telegram_miniapp_enabled` AND
|
||||
`cloud_auth_enabled` required) and the exchange flow: valid signed initData
|
||||
+ matching CEO chat id -> the same cloud-auth session cookie `/api/auth/login`
|
||||
issues; every refusal path (unconfigured creds, bad HMAC, wrong user id,
|
||||
oversized body) never mints a cookie.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import time
|
||||
from http import HTTPStatus
|
||||
from typing import TYPE_CHECKING
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from cryptography.fernet import Fernet
|
||||
from fastapi import FastAPI
|
||||
from fastapi_users.password import PasswordHelper
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from roboco.api.auth.backend import SESSION_COOKIE_NAME
|
||||
from roboco.api.deps import get_db
|
||||
from roboco.api.routes.telegram import mount_telegram_miniapp_auth, webapp_auth_router
|
||||
from roboco.config import settings
|
||||
from roboco.db.tables import UserTable
|
||||
from roboco.services.telegram_credentials import get_telegram_credentials_service
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
_BOT_TOKEN = "123456:TEST-bot-token"
|
||||
_CHAT_ID = "987654321"
|
||||
_SECRET = "test-secret-for-miniapp-padded-32bytes"
|
||||
_password_helper = PasswordHelper()
|
||||
|
||||
|
||||
def _sign(fields: dict[str, str], bot_token: str = _BOT_TOKEN) -> str:
|
||||
data_check_string = "\n".join(f"{k}={v}" for k, v in sorted(fields.items()))
|
||||
secret_key = hmac.new(b"WebAppData", bot_token.encode(), hashlib.sha256).digest()
|
||||
return hmac.new(secret_key, data_check_string.encode(), hashlib.sha256).hexdigest()
|
||||
|
||||
|
||||
def _init_data(user_id: str = _CHAT_ID, bot_token: str = _BOT_TOKEN) -> str:
|
||||
fields = {
|
||||
"auth_date": str(int(time.time())),
|
||||
"user": json.dumps({"id": int(user_id), "first_name": "Renzo"}),
|
||||
"query_id": "AAH_test",
|
||||
}
|
||||
signed = dict(fields)
|
||||
signed["hash"] = _sign(fields, bot_token)
|
||||
return urlencode(signed)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _armed_settings(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Every test gets a signing secret + encryption key + both flags on."""
|
||||
monkeypatch.setattr(settings, "cloud_auth_enabled", True)
|
||||
monkeypatch.setattr(settings, "cloud_auth_secret", _SECRET)
|
||||
monkeypatch.setattr(settings, "telegram_miniapp_enabled", True)
|
||||
monkeypatch.setattr(settings, "telegram_initdata_max_age_seconds", 600)
|
||||
monkeypatch.setattr(settings, "encryption_key", Fernet.generate_key().decode())
|
||||
|
||||
|
||||
async def _seed_creds(db: AsyncSession) -> None:
|
||||
await get_telegram_credentials_service(db).set_credentials(
|
||||
bot_token=_BOT_TOKEN, chat_id=_CHAT_ID
|
||||
)
|
||||
await db.flush()
|
||||
|
||||
|
||||
async def _seed_user(db: AsyncSession) -> UserTable:
|
||||
user = UserTable(
|
||||
email="ceo@example.com",
|
||||
hashed_password=_password_helper.hash("hunter2"),
|
||||
is_active=True,
|
||||
is_superuser=True,
|
||||
is_verified=True,
|
||||
)
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
return user
|
||||
|
||||
|
||||
def _build_app(db_session: AsyncSession) -> FastAPI:
|
||||
app = FastAPI()
|
||||
app.include_router(webapp_auth_router, prefix="/api/telegram")
|
||||
|
||||
async def _override_db() -> AsyncIterator[AsyncSession]:
|
||||
yield db_session
|
||||
|
||||
app.dependency_overrides[get_db] = _override_db
|
||||
return app
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def client(db_session: AsyncSession) -> AsyncIterator[AsyncClient]:
|
||||
app = _build_app(db_session)
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as c:
|
||||
yield c
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Conditional mount — both flags required. Driven through a real request
|
||||
# rather than introspecting `app.routes`: FastAPI wraps an included router in
|
||||
# a lazily-resolved `_IncludedRouter` that doesn't expose child paths
|
||||
# directly, so a live 404-vs-not check is the accurate signal.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _post_unmounted_probe(app: FastAPI) -> int:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as c:
|
||||
resp = await c.post("/api/telegram/webapp-auth", json={"init_data": "x"})
|
||||
return resp.status_code
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mount_skipped_when_miniapp_flag_off(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(settings, "telegram_miniapp_enabled", False)
|
||||
app = FastAPI()
|
||||
mount_telegram_miniapp_auth(app, "/api/telegram")
|
||||
assert await _post_unmounted_probe(app) == HTTPStatus.NOT_FOUND
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mount_skipped_when_cloud_auth_off(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(settings, "cloud_auth_enabled", False)
|
||||
app = FastAPI()
|
||||
mount_telegram_miniapp_auth(app, "/api/telegram")
|
||||
assert await _post_unmounted_probe(app) == HTTPStatus.NOT_FOUND
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mount_included_when_both_armed(db_session: AsyncSession) -> None:
|
||||
app = FastAPI()
|
||||
mount_telegram_miniapp_auth(app, "/api/telegram")
|
||||
|
||||
async def _override_db() -> AsyncIterator[AsyncSession]:
|
||||
yield db_session
|
||||
|
||||
app.dependency_overrides[get_db] = _override_db
|
||||
# Mounted -> never 404 (the unmounted signal); the exchange flow itself
|
||||
# is covered by the `client` fixture tests below.
|
||||
assert await _post_unmounted_probe(app) != HTTPStatus.NOT_FOUND
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Exchange flow.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_happy_path_sets_session_cookie(
|
||||
db_session: AsyncSession, client: AsyncClient
|
||||
) -> None:
|
||||
await _seed_creds(db_session)
|
||||
await _seed_user(db_session)
|
||||
resp = await client.post(
|
||||
"/api/telegram/webapp-auth", json={"init_data": _init_data()}
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.OK
|
||||
assert resp.json() == {"ok": True}
|
||||
assert resp.cookies.get(SESSION_COOKIE_NAME) is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wrong_user_id_refused(
|
||||
db_session: AsyncSession, client: AsyncClient
|
||||
) -> None:
|
||||
await _seed_creds(db_session)
|
||||
await _seed_user(db_session)
|
||||
resp = await client.post(
|
||||
"/api/telegram/webapp-auth",
|
||||
json={"init_data": _init_data(user_id="111111111")},
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.FORBIDDEN
|
||||
assert resp.cookies.get(SESSION_COOKIE_NAME) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bad_hmac_refused(db_session: AsyncSession, client: AsyncClient) -> None:
|
||||
await _seed_creds(db_session)
|
||||
await _seed_user(db_session)
|
||||
resp = await client.post(
|
||||
"/api/telegram/webapp-auth",
|
||||
json={"init_data": _init_data(bot_token="a-different-bot-token")},
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.UNAUTHORIZED
|
||||
assert resp.cookies.get(SESSION_COOKIE_NAME) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unconfigured_credentials_refused(client: AsyncClient) -> None:
|
||||
# No _seed_creds() call — credentials never set.
|
||||
resp = await client.post(
|
||||
"/api/telegram/webapp-auth", json={"init_data": _init_data()}
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.SERVICE_UNAVAILABLE
|
||||
assert resp.cookies.get(SESSION_COOKIE_NAME) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_oversized_body_refused(
|
||||
db_session: AsyncSession, client: AsyncClient
|
||||
) -> None:
|
||||
await _seed_creds(db_session)
|
||||
await _seed_user(db_session)
|
||||
resp = await client.post(
|
||||
"/api/telegram/webapp-auth", json={"init_data": "x" * 5000}
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
|
||||
assert resp.cookies.get(SESSION_COOKIE_NAME) is None
|
||||
@@ -34,7 +34,9 @@ class _BrokenRedis:
|
||||
def _app(redis: Any) -> FastAPI:
|
||||
app = FastAPI()
|
||||
app.state.login_redis = redis
|
||||
app.add_middleware(LoginRateLimiter, prefix="/auth", max_attempts=3, window=60)
|
||||
app.add_middleware(
|
||||
LoginRateLimiter, paths=("/auth/login",), max_attempts=3, window=60
|
||||
)
|
||||
|
||||
@app.post("/auth/login")
|
||||
async def login() -> dict[str, bool]:
|
||||
|
||||
@@ -90,6 +90,39 @@ def test_cloud_auth_ok_without_panel_agent_token(
|
||||
assert s.cloud_auth_enabled is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Telegram Mini App sign-in — requires cloud_auth_enabled
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_telegram_miniapp_off_does_not_require_cloud_auth() -> None:
|
||||
"""Default (off) construction never raises regardless of cloud auth."""
|
||||
s = Settings(telegram_miniapp_enabled=False, cloud_auth_enabled=False)
|
||||
assert s.telegram_miniapp_enabled is False
|
||||
|
||||
|
||||
def test_telegram_miniapp_enabled_without_cloud_auth_fails_loud() -> None:
|
||||
"""The Mini App route mints a cloud-auth session cookie — with cloud
|
||||
auth off there's nothing to mint, so this must fail at startup."""
|
||||
with pytest.raises(ValueError, match="ROBOCO_TELEGRAM_MINIAPP_ENABLED"):
|
||||
Settings(telegram_miniapp_enabled=True, cloud_auth_enabled=False)
|
||||
|
||||
|
||||
def test_telegram_miniapp_enabled_with_cloud_auth_succeeds() -> None:
|
||||
s = Settings(
|
||||
telegram_miniapp_enabled=True,
|
||||
cloud_auth_enabled=True,
|
||||
cloud_auth_secret="s" * 32,
|
||||
)
|
||||
assert s.telegram_miniapp_enabled is True
|
||||
|
||||
|
||||
def test_telegram_initdata_max_age_defaults_to_600() -> None:
|
||||
ten_minutes = 10 * 60
|
||||
s = Settings()
|
||||
assert s.telegram_initdata_max_age_seconds == ten_minutes
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# local_llm_base_url — internal-host guard (H13)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
"""``validate_init_data`` coverage — hand-computed HMAC vectors pin the exact
|
||||
algorithm shape (HMAC key=b"WebAppData"/msg=bot_token for the secret, then
|
||||
HMAC key=secret/msg=data_check_string for the hash), plus tamper/expiry/
|
||||
missing-field cases. Pure function, no I/O — no DB/network fixtures needed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import time
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from roboco.utils.telegram_initdata import validate_init_data
|
||||
|
||||
_BOT_TOKEN = "123456:TEST-bot-token-for-unit-tests"
|
||||
|
||||
|
||||
def _sign(fields: dict[str, str], bot_token: str = _BOT_TOKEN) -> str:
|
||||
"""Reference HMAC computation, independent of the module under test."""
|
||||
data_check_string = "\n".join(f"{k}={v}" for k, v in sorted(fields.items()))
|
||||
secret_key = hmac.new(b"WebAppData", bot_token.encode(), hashlib.sha256).digest()
|
||||
return hmac.new(secret_key, data_check_string.encode(), hashlib.sha256).hexdigest()
|
||||
|
||||
|
||||
def _init_data(fields: dict[str, str], bot_token: str = _BOT_TOKEN) -> str:
|
||||
signed = dict(fields)
|
||||
signed["hash"] = _sign(fields, bot_token)
|
||||
return urlencode(signed)
|
||||
|
||||
|
||||
def test_valid_init_data_returns_parsed_fields_with_user_decoded() -> None:
|
||||
user = {"id": 987654321, "first_name": "Renzo"}
|
||||
fields = {
|
||||
"auth_date": str(int(time.time())),
|
||||
"user": json.dumps(user),
|
||||
"query_id": "AAH_abc123",
|
||||
}
|
||||
result = validate_init_data(_init_data(fields), _BOT_TOKEN, max_age_seconds=600)
|
||||
assert result is not None
|
||||
assert result["user"] == user
|
||||
assert result["query_id"] == "AAH_abc123"
|
||||
|
||||
|
||||
def test_missing_hash_rejected() -> None:
|
||||
fields = {"auth_date": str(int(time.time()))}
|
||||
init_data = urlencode(fields) # no hash field at all
|
||||
assert validate_init_data(init_data, _BOT_TOKEN, max_age_seconds=600) is None
|
||||
|
||||
|
||||
def test_tampered_field_after_signing_rejected() -> None:
|
||||
fields = {"auth_date": str(int(time.time())), "user": json.dumps({"id": 1})}
|
||||
signed_hash = _sign(fields)
|
||||
tampered = dict(fields)
|
||||
tampered["auth_date"] = str(int(time.time()) + 999) # changed post-signing
|
||||
tampered["hash"] = signed_hash
|
||||
init_data = urlencode(tampered)
|
||||
assert validate_init_data(init_data, _BOT_TOKEN, max_age_seconds=600) is None
|
||||
|
||||
|
||||
def test_wrong_bot_token_rejected() -> None:
|
||||
fields = {"auth_date": str(int(time.time()))}
|
||||
init_data = _init_data(fields, bot_token=_BOT_TOKEN)
|
||||
assert validate_init_data(init_data, "wrong-token", max_age_seconds=600) is None
|
||||
|
||||
|
||||
def test_expired_auth_date_rejected() -> None:
|
||||
stale = int(time.time()) - 3600
|
||||
fields = {"auth_date": str(stale)}
|
||||
init_data = _init_data(fields)
|
||||
assert validate_init_data(init_data, _BOT_TOKEN, max_age_seconds=600) is None
|
||||
|
||||
|
||||
def test_fresh_auth_date_within_window_accepted() -> None:
|
||||
fields = {"auth_date": str(int(time.time()) - 10)}
|
||||
init_data = _init_data(fields)
|
||||
assert validate_init_data(init_data, _BOT_TOKEN, max_age_seconds=600) is not None
|
||||
|
||||
|
||||
def test_far_future_auth_date_rejected() -> None:
|
||||
# A far-future auth_date is nonsense from a server-stamped field; without
|
||||
# an upper bound it would count as eternally fresh.
|
||||
fields = {"auth_date": str(int(time.time()) + 100_000)}
|
||||
init_data = _init_data(fields)
|
||||
assert validate_init_data(init_data, _BOT_TOKEN, max_age_seconds=600) is None
|
||||
|
||||
|
||||
def test_slightly_future_auth_date_within_skew_tolerance_accepted() -> None:
|
||||
# Local clock lagging Telegram's by a few seconds must not break login.
|
||||
fields = {"auth_date": str(int(time.time()) + 30)}
|
||||
init_data = _init_data(fields)
|
||||
assert validate_init_data(init_data, _BOT_TOKEN, max_age_seconds=600) is not None
|
||||
|
||||
|
||||
def test_missing_auth_date_rejected() -> None:
|
||||
fields = {"query_id": "abc"}
|
||||
init_data = _init_data(fields)
|
||||
assert validate_init_data(init_data, _BOT_TOKEN, max_age_seconds=600) is None
|
||||
|
||||
|
||||
def test_malformed_user_json_rejected() -> None:
|
||||
fields = {"auth_date": str(int(time.time())), "user": "not-json"}
|
||||
init_data = _init_data(fields)
|
||||
assert validate_init_data(init_data, _BOT_TOKEN, max_age_seconds=600) is None
|
||||
|
||||
|
||||
def test_empty_init_data_rejected() -> None:
|
||||
assert validate_init_data("", _BOT_TOKEN, max_age_seconds=600) is None
|
||||
|
||||
|
||||
def test_empty_bot_token_rejected() -> None:
|
||||
fields = {"auth_date": str(int(time.time()))}
|
||||
init_data = _init_data(fields)
|
||||
assert validate_init_data(init_data, "", max_age_seconds=600) is None
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# ponytail: smallest runnable self-check; pytest is the real suite above.
|
||||
user = {"id": 42}
|
||||
fields = {"auth_date": str(int(time.time())), "user": json.dumps(user)}
|
||||
assert validate_init_data(_init_data(fields), _BOT_TOKEN, 600)["user"] == user
|
||||
assert validate_init_data(_init_data(fields), "wrong", 600) is None
|
||||
print("telegram_initdata self-check OK")
|
||||
Reference in New Issue
Block a user