feat: sync Telegram Login OIDC provider
This commit is contained in:
parent
30774f8c39
commit
ebead9e98c
63 changed files with 11374 additions and 37 deletions
18
.env.example
18
.env.example
|
|
@ -159,6 +159,24 @@ TELESRV_STICKER_SEED_DIR=data/sticker-seed
|
|||
# through nginx; public canonical URLs use TELESRV_PUBLIC_BASE_URL.
|
||||
TELESRV_PUBLIC_LINK_WEB_ADDR=127.0.0.1:2401
|
||||
|
||||
# Self-hosted Telegram Login / OpenID Connect. The provider is mounted on the
|
||||
# public-link listener above. Keep disabled until all three local key files
|
||||
# have been generated with `go run ./cmd/telegramloginkeygen -mode init`.
|
||||
TELESRV_TELEGRAM_LOGIN_ENABLE=false
|
||||
TELESRV_TELEGRAM_LOGIN_ISSUER=https://telesrv.net
|
||||
TELESRV_TELEGRAM_LOGIN_ALLOW_LOOPBACK_HTTP=false
|
||||
TELESRV_TELEGRAM_LOGIN_SIGNING_KEYS_FILE=data/telegram-login/signing-keys.json
|
||||
TELESRV_TELEGRAM_LOGIN_CODE_KEYS_FILE=data/telegram-login/code-keys.json
|
||||
TELESRV_TELEGRAM_LOGIN_SECRET_PEPPER_FILE=data/telegram-login/client-secret-pepper
|
||||
TELESRV_TELEGRAM_LOGIN_REQUEST_TTL=5m
|
||||
TELESRV_TELEGRAM_LOGIN_CODE_TTL=2m
|
||||
TELESRV_TELEGRAM_LOGIN_ID_TOKEN_TTL=1h
|
||||
# Trust only the loopback nginx hop in the documented single-host deployment.
|
||||
TELESRV_TELEGRAM_LOGIN_TRUSTED_PROXY_CIDRS=127.0.0.1/32,::1/128
|
||||
TELESRV_TELEGRAM_LOGIN_RETENTION=168h
|
||||
TELESRV_TELEGRAM_LOGIN_SWEEP_INTERVAL=5m
|
||||
TELESRV_TELEGRAM_LOGIN_SWEEP_BATCH=500
|
||||
|
||||
# AI compose for TDesktop/Android input box rewrite/polish.
|
||||
# The local provider is deterministic and does not call external services.
|
||||
TELESRV_AI_ENABLED=true
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
||||
|
|
|
|||
462
cmd/bots/bedolagaformat/login_demo.py
Normal file
462
cmd/bots/bedolagaformat/login_demo.py
Normal 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
|
||||
|
|
@ -1 +1,2 @@
|
|||
aiogram==3.30.0
|
||||
PyJWT[crypto]==2.10.1
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
154
cmd/bots/bedolagaformat/test_login_demo.py
Normal file
154
cmd/bots/bedolagaformat/test_login_demo.py
Normal 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()
|
||||
391
cmd/telegramloginkeygen/main.go
Normal file
391
cmd/telegramloginkeygen/main.go
Normal 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
|
||||
}
|
||||
109
cmd/telegramloginkeygen/main_test.go
Normal file
109
cmd/telegramloginkeygen/main_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
14
deploy/migrations/0125_telegram_login_oidc.down.sql
Normal file
14
deploy/migrations/0125_telegram_login_oidc.down.sql
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
DROP TABLE IF EXISTS public.telegram_login_codes;
|
||||
DROP TABLE IF EXISTS public.web_authorizations;
|
||||
DROP TABLE IF EXISTS public.telegram_login_requests;
|
||||
DROP TABLE IF EXISTS public.bot_login_native_apps;
|
||||
DROP TABLE IF EXISTS public.bot_login_allowed_urls;
|
||||
DROP TABLE IF EXISTS public.bot_login_clients;
|
||||
UPDATE public.bots
|
||||
SET commands = COALESCE((
|
||||
SELECT jsonb_agg(command ORDER BY ordinal)
|
||||
FROM jsonb_array_elements(commands) WITH ORDINALITY AS item(command, ordinal)
|
||||
WHERE command->>'command' NOT IN ('setlogin','logininfo','resetloginsecret')
|
||||
), '[]'::jsonb),
|
||||
updated_at = now()
|
||||
WHERE bot_user_id = 93372553;
|
||||
253
deploy/migrations/0125_telegram_login_oidc.up.sql
Normal file
253
deploy/migrations/0125_telegram_login_oidc.up.sql
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
-- Telegram Login / OIDC is one durable authorization aggregate shared by the
|
||||
-- public HTTP provider and MTProto URL-auth RPCs. PostgreSQL is authoritative;
|
||||
-- Redis/NOTIFY may wake waiters but may not own any transition below.
|
||||
UPDATE public.bots
|
||||
SET commands = commands || '[
|
||||
{"command":"setlogin","description":"configure Telegram Login"},
|
||||
{"command":"logininfo","description":"show Telegram Login configuration"},
|
||||
{"command":"resetloginsecret","description":"rotate an OIDC Client Secret"}
|
||||
]'::jsonb,
|
||||
updated_at = now()
|
||||
WHERE bot_user_id = 93372553;
|
||||
|
||||
CREATE TABLE public.bot_login_clients (
|
||||
bot_user_id bigint PRIMARY KEY REFERENCES public.bots(bot_user_id) ON DELETE CASCADE,
|
||||
client_id text NOT NULL UNIQUE,
|
||||
client_secret_hash bytea NOT NULL,
|
||||
secret_version bigint DEFAULT 1 NOT NULL,
|
||||
signing_algorithm text DEFAULT 'RS256'::text NOT NULL,
|
||||
enabled boolean DEFAULT true NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT bot_login_clients_client_id_check
|
||||
CHECK (client_id = bot_user_id::text AND length(client_id) BETWEEN 1 AND 64),
|
||||
CONSTRAINT bot_login_clients_secret_hash_check CHECK (octet_length(client_secret_hash) = 32),
|
||||
CONSTRAINT bot_login_clients_secret_version_check CHECK (secret_version > 0),
|
||||
CONSTRAINT bot_login_clients_signing_algorithm_check
|
||||
CHECK (signing_algorithm IN ('RS256','ES256','EdDSA','ES256K'))
|
||||
);
|
||||
|
||||
CREATE TABLE public.bot_login_allowed_urls (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
bot_user_id bigint NOT NULL REFERENCES public.bot_login_clients(bot_user_id) ON DELETE CASCADE,
|
||||
kind text NOT NULL,
|
||||
normalized_url text NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT bot_login_allowed_urls_kind_check CHECK (kind IN ('web_origin','redirect_uri')),
|
||||
CONSTRAINT bot_login_allowed_urls_value_check CHECK (length(normalized_url) BETWEEN 1 AND 4096),
|
||||
UNIQUE (bot_user_id, kind, normalized_url)
|
||||
);
|
||||
|
||||
CREATE INDEX bot_login_allowed_urls_bot_page_idx
|
||||
ON public.bot_login_allowed_urls(bot_user_id, kind, id);
|
||||
|
||||
CREATE TABLE public.bot_login_native_apps (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
bot_user_id bigint NOT NULL REFERENCES public.bot_login_clients(bot_user_id) ON DELETE CASCADE,
|
||||
platform text NOT NULL,
|
||||
application_id text NOT NULL,
|
||||
verification_id text NOT NULL,
|
||||
callback_uri text NOT NULL,
|
||||
verified_display_name text NOT NULL,
|
||||
enabled boolean DEFAULT true NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT bot_login_native_apps_platform_check CHECK (platform IN ('ios','android')),
|
||||
CONSTRAINT bot_login_native_apps_app_id_check
|
||||
CHECK (length(application_id) BETWEEN 3 AND 255 AND application_id ~ '^[A-Za-z0-9][A-Za-z0-9._-]*$'),
|
||||
CONSTRAINT bot_login_native_apps_verification_check CHECK (
|
||||
(platform = 'ios' AND verification_id ~ '^[A-Z0-9]{10}$')
|
||||
OR (platform = 'android' AND verification_id ~ '^[0-9A-F]{64}$')
|
||||
),
|
||||
CONSTRAINT bot_login_native_apps_callback_check CHECK (length(callback_uri) BETWEEN 1 AND 4096),
|
||||
CONSTRAINT bot_login_native_apps_name_check CHECK (length(btrim(verified_display_name)) BETWEEN 1 AND 128),
|
||||
UNIQUE (bot_user_id, platform, application_id, verification_id),
|
||||
UNIQUE (bot_user_id, callback_uri)
|
||||
);
|
||||
|
||||
CREATE INDEX bot_login_native_apps_bot_page_idx
|
||||
ON public.bot_login_native_apps(bot_user_id, id);
|
||||
CREATE INDEX bot_login_native_apps_callback_idx
|
||||
ON public.bot_login_native_apps(bot_user_id, callback_uri) WHERE enabled;
|
||||
|
||||
CREATE TABLE public.telegram_login_requests (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
request_token_hash bytea NOT NULL UNIQUE,
|
||||
browser_token_hash bytea NOT NULL UNIQUE,
|
||||
bot_user_id bigint NOT NULL REFERENCES public.bot_login_clients(bot_user_id) ON DELETE CASCADE,
|
||||
client_id text NOT NULL,
|
||||
signing_algorithm text NOT NULL,
|
||||
source text NOT NULL,
|
||||
response_type text NOT NULL,
|
||||
redirect_uri text NOT NULL,
|
||||
origin text DEFAULT ''::text NOT NULL,
|
||||
domain text NOT NULL,
|
||||
requested_scopes text[] NOT NULL,
|
||||
oauth_state text DEFAULT ''::text NOT NULL,
|
||||
nonce text DEFAULT ''::text NOT NULL,
|
||||
code_challenge text NOT NULL,
|
||||
code_challenge_method text NOT NULL,
|
||||
browser text NOT NULL,
|
||||
platform text NOT NULL,
|
||||
ip text NOT NULL,
|
||||
region text NOT NULL,
|
||||
in_app_origin text DEFAULT ''::text NOT NULL,
|
||||
is_app boolean DEFAULT false NOT NULL,
|
||||
verified_app_name text DEFAULT ''::text NOT NULL,
|
||||
match_codes text[] DEFAULT '{}'::text[] NOT NULL,
|
||||
match_code text DEFAULT ''::text NOT NULL,
|
||||
match_codes_first boolean DEFAULT false NOT NULL,
|
||||
user_id_hint bigint DEFAULT 0 NOT NULL,
|
||||
peer_type text DEFAULT ''::text NOT NULL,
|
||||
peer_id bigint DEFAULT 0 NOT NULL,
|
||||
message_id integer DEFAULT 0 NOT NULL,
|
||||
button_id integer DEFAULT 0 NOT NULL,
|
||||
status text DEFAULT 'pending'::text NOT NULL,
|
||||
authorized_user_id bigint REFERENCES public.users(id),
|
||||
profile_name text DEFAULT ''::text NOT NULL,
|
||||
given_name text DEFAULT ''::text NOT NULL,
|
||||
family_name text DEFAULT ''::text NOT NULL,
|
||||
preferred_username text DEFAULT ''::text NOT NULL,
|
||||
picture text DEFAULT ''::text NOT NULL,
|
||||
phone_number text DEFAULT ''::text NOT NULL,
|
||||
write_allowed boolean DEFAULT false NOT NULL,
|
||||
phone_shared boolean DEFAULT false NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
expires_at timestamp with time zone NOT NULL,
|
||||
approved_at timestamp with time zone,
|
||||
declined_at timestamp with time zone,
|
||||
CONSTRAINT telegram_login_requests_hashes_check
|
||||
CHECK (octet_length(request_token_hash) = 32 AND octet_length(browser_token_hash) = 32),
|
||||
CONSTRAINT telegram_login_requests_signing_algorithm_check
|
||||
CHECK (signing_algorithm IN ('RS256','ES256','EdDSA','ES256K')),
|
||||
CONSTRAINT telegram_login_requests_source_check
|
||||
CHECK (source IN ('web','javascript','native','mini_app','message_button')),
|
||||
CONSTRAINT telegram_login_requests_response_type_check CHECK (response_type IN ('code','post_message','legacy_url')),
|
||||
CONSTRAINT telegram_login_requests_source_response_check CHECK (
|
||||
(source = 'web' AND response_type = 'code')
|
||||
OR (source = 'javascript' AND response_type = 'post_message')
|
||||
OR (source = 'native' AND response_type = 'code')
|
||||
OR (source = 'mini_app' AND response_type = 'post_message')
|
||||
OR (source = 'message_button' AND response_type = 'legacy_url')
|
||||
),
|
||||
CONSTRAINT telegram_login_requests_url_check
|
||||
CHECK (length(redirect_uri) BETWEEN 1 AND 4096 AND length(origin) <= 4096
|
||||
AND length(domain) BETWEEN 1 AND 255 AND length(in_app_origin) <= 4096),
|
||||
CONSTRAINT telegram_login_requests_scope_check
|
||||
CHECK (cardinality(requested_scopes) BETWEEN 1 AND 4 AND requested_scopes @> ARRAY['openid']::text[]),
|
||||
CONSTRAINT telegram_login_requests_oauth_value_check
|
||||
CHECK (length(oauth_state) <= 2048 AND length(nonce) <= 1024),
|
||||
CONSTRAINT telegram_login_requests_pkce_check CHECK (
|
||||
(response_type = 'code' AND code_challenge_method = 'S256' AND length(code_challenge) BETWEEN 43 AND 128)
|
||||
OR (response_type = 'post_message' AND (
|
||||
(code_challenge = '' AND code_challenge_method = '')
|
||||
OR (code_challenge_method = 'S256' AND length(code_challenge) BETWEEN 43 AND 128)))
|
||||
OR (response_type = 'legacy_url' AND code_challenge = '' AND code_challenge_method = '')
|
||||
),
|
||||
CONSTRAINT telegram_login_requests_device_check
|
||||
CHECK (length(browser) BETWEEN 1 AND 255 AND length(platform) BETWEEN 1 AND 255 AND length(ip) BETWEEN 1 AND 128 AND length(region) BETWEEN 1 AND 255),
|
||||
CONSTRAINT telegram_login_requests_match_codes_check
|
||||
CHECK (cardinality(match_codes) <= 8
|
||||
AND (cardinality(match_codes) = 0 OR (match_code <> '' AND match_code = ANY(match_codes)))
|
||||
AND (NOT match_codes_first OR cardinality(match_codes) > 0)),
|
||||
CONSTRAINT telegram_login_requests_context_check
|
||||
CHECK (user_id_hint >= 0 AND peer_id >= 0 AND message_id >= 0 AND button_id >= 0),
|
||||
CONSTRAINT telegram_login_requests_app_shape_check CHECK (
|
||||
(source = 'native' AND is_app AND verified_app_name <> '' AND origin = '')
|
||||
OR (source <> 'native' AND NOT is_app AND verified_app_name = '' AND origin <> '')
|
||||
),
|
||||
CONSTRAINT telegram_login_requests_in_app_shape_check CHECK (
|
||||
(source = 'mini_app' AND response_type = 'post_message'
|
||||
AND in_app_origin <> '' AND origin = in_app_origin)
|
||||
OR (source <> 'mini_app' AND in_app_origin = '')
|
||||
),
|
||||
CONSTRAINT telegram_login_requests_consent_scope_check CHECK (
|
||||
(NOT write_allowed OR 'telegram:bot_access' = ANY(requested_scopes))
|
||||
AND (NOT phone_shared OR 'phone' = ANY(requested_scopes))
|
||||
AND ((phone_shared AND phone_number <> '') OR (NOT phone_shared AND phone_number = ''))
|
||||
),
|
||||
CONSTRAINT telegram_login_requests_status_check CHECK (status IN ('pending','approved','declined','expired')),
|
||||
CONSTRAINT telegram_login_requests_claims_check CHECK (
|
||||
length(profile_name) <= 255 AND length(given_name) <= 255 AND length(family_name) <= 255
|
||||
AND length(preferred_username) <= 64 AND length(picture) <= 4096 AND length(phone_number) <= 32
|
||||
),
|
||||
CONSTRAINT telegram_login_requests_time_check CHECK (expires_at > created_at),
|
||||
CONSTRAINT telegram_login_requests_terminal_shape_check CHECK (
|
||||
(status = 'pending' AND authorized_user_id IS NULL AND profile_name = '' AND given_name = ''
|
||||
AND family_name = '' AND preferred_username = '' AND picture = '' AND phone_number = ''
|
||||
AND NOT write_allowed AND NOT phone_shared AND approved_at IS NULL AND declined_at IS NULL)
|
||||
OR (status = 'approved' AND authorized_user_id IS NOT NULL AND approved_at IS NOT NULL AND declined_at IS NULL
|
||||
AND (('profile' = ANY(requested_scopes) AND profile_name <> '' AND given_name <> '')
|
||||
OR (NOT ('profile' = ANY(requested_scopes)) AND profile_name = '' AND given_name = ''
|
||||
AND family_name = '' AND preferred_username = '' AND picture = '')))
|
||||
OR (status = 'declined' AND authorized_user_id IS NULL AND profile_name = '' AND given_name = ''
|
||||
AND family_name = '' AND preferred_username = '' AND picture = '' AND phone_number = ''
|
||||
AND NOT write_allowed AND NOT phone_shared AND approved_at IS NULL AND declined_at IS NOT NULL)
|
||||
OR (status = 'expired' AND authorized_user_id IS NULL AND profile_name = '' AND given_name = ''
|
||||
AND family_name = '' AND preferred_username = '' AND picture = '' AND phone_number = ''
|
||||
AND NOT write_allowed AND NOT phone_shared AND approved_at IS NULL AND declined_at IS NULL)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX telegram_login_requests_expiry_idx
|
||||
ON public.telegram_login_requests(expires_at, id) WHERE status = 'pending';
|
||||
CREATE INDEX telegram_login_requests_user_active_idx
|
||||
ON public.telegram_login_requests(authorized_user_id, approved_at DESC, id DESC)
|
||||
WHERE status = 'approved';
|
||||
|
||||
CREATE TABLE public.web_authorizations (
|
||||
hash bigint PRIMARY KEY,
|
||||
request_id bigint NOT NULL UNIQUE REFERENCES public.telegram_login_requests(id) ON DELETE CASCADE,
|
||||
user_id bigint NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
|
||||
bot_user_id bigint NOT NULL REFERENCES public.bots(bot_user_id) ON DELETE CASCADE,
|
||||
domain text NOT NULL,
|
||||
browser text NOT NULL,
|
||||
platform text NOT NULL,
|
||||
ip text NOT NULL,
|
||||
region text NOT NULL,
|
||||
granted_scopes text[] NOT NULL,
|
||||
phone_shared boolean DEFAULT false NOT NULL,
|
||||
bot_access_granted boolean DEFAULT false NOT NULL,
|
||||
created_at timestamp with time zone NOT NULL,
|
||||
last_active_at timestamp with time zone NOT NULL,
|
||||
revoked_at timestamp with time zone,
|
||||
CONSTRAINT web_authorizations_hash_check CHECK (hash <> 0),
|
||||
CONSTRAINT web_authorizations_identity_check CHECK (user_id > 0 AND bot_user_id > 0),
|
||||
CONSTRAINT web_authorizations_text_check
|
||||
CHECK (length(domain) BETWEEN 1 AND 255 AND length(browser) BETWEEN 1 AND 255
|
||||
AND length(platform) BETWEEN 1 AND 255 AND length(ip) BETWEEN 1 AND 128
|
||||
AND length(region) BETWEEN 1 AND 255),
|
||||
CONSTRAINT web_authorizations_scope_check
|
||||
CHECK (cardinality(granted_scopes) BETWEEN 1 AND 4 AND granted_scopes @> ARRAY['openid']::text[]
|
||||
AND (NOT phone_shared OR 'phone' = ANY(granted_scopes))
|
||||
AND (NOT bot_access_granted OR 'telegram:bot_access' = ANY(granted_scopes))),
|
||||
CONSTRAINT web_authorizations_time_check
|
||||
CHECK (last_active_at >= created_at AND (revoked_at IS NULL OR revoked_at >= created_at))
|
||||
);
|
||||
|
||||
CREATE INDEX web_authorizations_user_active_page_idx
|
||||
ON public.web_authorizations(user_id, last_active_at DESC, hash DESC)
|
||||
WHERE revoked_at IS NULL;
|
||||
CREATE INDEX web_authorizations_bot_active_idx
|
||||
ON public.web_authorizations(bot_user_id, user_id, hash)
|
||||
WHERE revoked_at IS NULL;
|
||||
|
||||
CREATE TABLE public.telegram_login_codes (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
request_id bigint NOT NULL UNIQUE REFERENCES public.telegram_login_requests(id) ON DELETE CASCADE,
|
||||
code_hash bytea NOT NULL UNIQUE,
|
||||
sealed_code bytea NOT NULL,
|
||||
seal_nonce bytea NOT NULL,
|
||||
seal_key_id text NOT NULL,
|
||||
issued_at timestamp with time zone NOT NULL,
|
||||
expires_at timestamp with time zone NOT NULL,
|
||||
consumed_at timestamp with time zone,
|
||||
CONSTRAINT telegram_login_codes_hash_check CHECK (octet_length(code_hash) = 32),
|
||||
CONSTRAINT telegram_login_codes_sealed_check
|
||||
CHECK (octet_length(sealed_code) >= 32 AND octet_length(seal_nonce) >= 12 AND length(seal_key_id) BETWEEN 1 AND 128),
|
||||
CONSTRAINT telegram_login_codes_time_check
|
||||
CHECK (expires_at > issued_at AND (consumed_at IS NULL OR (consumed_at >= issued_at AND consumed_at < expires_at)))
|
||||
);
|
||||
|
||||
CREATE INDEX telegram_login_codes_expiry_idx
|
||||
ON public.telegram_login_codes(expires_at, id) WHERE consumed_at IS NULL;
|
||||
10
go.mod
10
go.mod
|
|
@ -3,6 +3,7 @@ module telesrv
|
|||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1
|
||||
github.com/fxamacker/cbor/v2 v2.8.0
|
||||
github.com/go-faster/errors v0.7.1
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1
|
||||
|
|
@ -11,6 +12,7 @@ require (
|
|||
github.com/iamxvbaba/td v1.1.3
|
||||
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa
|
||||
github.com/jackc/pgx/v5 v5.9.2
|
||||
github.com/lestrrat-go/jwx/v3 v3.1.1
|
||||
github.com/pion/datachannel v1.6.2
|
||||
github.com/pion/dtls/v3 v3.1.4
|
||||
github.com/pion/ice/v4 v4.2.7
|
||||
|
|
@ -43,6 +45,7 @@ require (
|
|||
github.com/go-faster/jx v1.2.0 // indirect
|
||||
github.com/go-faster/xor v1.0.0 // indirect
|
||||
github.com/go-faster/yaml v0.4.6 // indirect
|
||||
github.com/goccy/go-json v0.10.6 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gotd/log v0.1.0 // indirect
|
||||
github.com/gotd/neo v0.1.5 // indirect
|
||||
|
|
@ -52,6 +55,12 @@ require (
|
|||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/klauspost/compress v1.19.0 // indirect
|
||||
github.com/lestrrat-go/blackmagic v1.0.4 // indirect
|
||||
github.com/lestrrat-go/dsig v1.2.1 // indirect
|
||||
github.com/lestrrat-go/dsig-secp256k1 v1.0.0 // indirect
|
||||
github.com/lestrrat-go/httpcc v1.0.1 // indirect
|
||||
github.com/lestrrat-go/httprc/v3 v3.0.5 // indirect
|
||||
github.com/lestrrat-go/option/v2 v2.0.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.22 // indirect
|
||||
github.com/mitchellh/mapstructure v1.4.1 // indirect
|
||||
|
|
@ -64,6 +73,7 @@ require (
|
|||
github.com/segmentio/asm v1.2.1 // indirect
|
||||
github.com/shopspring/decimal v1.4.0 // indirect
|
||||
github.com/sirupsen/logrus v1.9.3 // indirect
|
||||
github.com/valyala/fastjson v1.6.10 // indirect
|
||||
github.com/wlynxg/anet v0.0.5 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
github.com/yuin/goldmark v1.8.2 // indirect
|
||||
|
|
|
|||
20
go.sum
20
go.sum
|
|
@ -22,6 +22,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
|
|||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
|
||||
github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4=
|
||||
github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU=
|
||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||
|
|
@ -57,6 +59,8 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
|||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
|
||||
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
|
||||
|
|
@ -98,6 +102,20 @@ github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
|||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA=
|
||||
github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
|
||||
github.com/lestrrat-go/dsig v1.2.1 h1:MwxzZhE4+4fguHi+uDALKVlC3Cn+O1QU1Q/F8D7hVIc=
|
||||
github.com/lestrrat-go/dsig v1.2.1/go.mod h1:RD2eOaidyPvpc7IJQoO3Qq52RWdy8ZcJs8lrOnoa1Kc=
|
||||
github.com/lestrrat-go/dsig-secp256k1 v1.0.0 h1:JpDe4Aybfl0soBvoVwjqDbp+9S1Y2OM7gcrVVMFPOzY=
|
||||
github.com/lestrrat-go/dsig-secp256k1 v1.0.0/go.mod h1:CxUgAhssb8FToqbL8NjSPoGQlnO4w3LG1P0qPWQm/NU=
|
||||
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
|
||||
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
|
||||
github.com/lestrrat-go/httprc/v3 v3.0.5 h1:S+Mb4L2I+bM6JGTibLmxExhyTOqnXjqx+zi9MoXw/TM=
|
||||
github.com/lestrrat-go/httprc/v3 v3.0.5/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0=
|
||||
github.com/lestrrat-go/jwx/v3 v3.1.1 h1:yd9AdPmZ4INnQ7k42IrzXYpnEG803+SrQ6hdMvzHJzw=
|
||||
github.com/lestrrat-go/jwx/v3 v3.1.1/go.mod h1:uw/MN2M/Xiu4FhwcIwH11Zsh9JWx9SWzgALl7/uIEkU=
|
||||
github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss=
|
||||
github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
|
|
@ -175,6 +193,8 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO
|
|||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4=
|
||||
github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE=
|
||||
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
|
||||
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import (
|
|||
|
||||
"go.uber.org/zap"
|
||||
|
||||
telegramloginapp "telesrv/internal/app/telegramlogin"
|
||||
"telesrv/internal/branding"
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
|
@ -33,6 +34,9 @@ const (
|
|||
botFatherCmdSetInlineFB = "setinlinefeedback"
|
||||
botFatherCmdSetJoinGroups = "setjoingroups"
|
||||
botFatherCmdSetPrivacy = "setprivacy"
|
||||
botFatherCmdSetLogin = "setlogin"
|
||||
botFatherCmdLoginInfo = "logininfo"
|
||||
botFatherCmdResetLogin = "resetloginsecret"
|
||||
|
||||
botFatherStepName = "name"
|
||||
botFatherStepUsername = "username"
|
||||
|
|
@ -60,6 +64,9 @@ You can control me by sending these commands:
|
|||
/setinlinefeedback - change inline feedback settings
|
||||
/setjoingroups - toggle whether a bot can join groups
|
||||
/setprivacy - toggle a bot's group privacy mode
|
||||
/setlogin - configure Telegram Login allowed URLs and signing
|
||||
/logininfo - show a bot's Telegram Login configuration
|
||||
/resetloginsecret - rotate a bot's OIDC Client Secret
|
||||
/cancel - cancel the current operation
|
||||
/help - show this message`
|
||||
|
||||
|
|
@ -175,6 +182,7 @@ var botFatherGlobalCommands = map[string]bool{
|
|||
botFatherCmdSetName: true, botFatherCmdSetDescription: true, botFatherCmdSetAbout: true,
|
||||
botFatherCmdSetCommands: true, botFatherCmdSetInline: true, botFatherCmdSetInlineGeo: true,
|
||||
botFatherCmdSetInlineFB: true, botFatherCmdSetJoinGroups: true, botFatherCmdSetPrivacy: true,
|
||||
botFatherCmdSetLogin: true, botFatherCmdLoginInfo: true, botFatherCmdResetLogin: true,
|
||||
}
|
||||
|
||||
func (s *Service) handleBotFather(ctx context.Context, userID int64, text string) botReply {
|
||||
|
|
@ -231,6 +239,9 @@ var pickerPrompts = map[string]string{
|
|||
botFatherCmdSetInlineGeo: "Choose a bot to change inline location requests for. Send the bot's username:",
|
||||
botFatherCmdSetJoinGroups: "Choose a bot to configure group joining for. Send the bot's username:",
|
||||
botFatherCmdSetPrivacy: "Choose a bot to configure group privacy for. Send the bot's username:",
|
||||
botFatherCmdSetLogin: "Choose a bot to configure Telegram Login for. Send the bot's username:",
|
||||
botFatherCmdLoginInfo: "Choose a bot whose Telegram Login configuration you want to inspect:",
|
||||
botFatherCmdResetLogin: "Choose a bot whose OIDC Client Secret you want to rotate:",
|
||||
}
|
||||
|
||||
// startBotPicker 列出 owner 的 bot 并进入 choose step(所有需先选 bot 的命令共用)。
|
||||
|
|
@ -322,7 +333,8 @@ func (s *Service) handleBotFatherCommand(ctx context.Context, userID int64, cmd
|
|||
case botFatherCmdToken, botFatherCmdRevoke,
|
||||
botFatherCmdSetName, botFatherCmdSetDescription, botFatherCmdSetAbout,
|
||||
botFatherCmdSetCommands, botFatherCmdSetInline, botFatherCmdSetInlineGeo,
|
||||
botFatherCmdSetJoinGroups, botFatherCmdSetPrivacy:
|
||||
botFatherCmdSetJoinGroups, botFatherCmdSetPrivacy,
|
||||
botFatherCmdSetLogin, botFatherCmdLoginInfo, botFatherCmdResetLogin:
|
||||
return s.startBotPicker(ctx, userID, cmd)
|
||||
case botFatherCmdSetInlineFB:
|
||||
_ = s.bots.DeleteBotChatState(ctx, domain.BotFatherUserID, userID)
|
||||
|
|
@ -351,6 +363,8 @@ func valuePrompt(cmd, username string) string {
|
|||
return fmt.Sprintf("Send 'enable' to allow @%s to be added to groups, or 'disable' to prevent it.", username)
|
||||
case botFatherCmdSetPrivacy:
|
||||
return fmt.Sprintf("Send 'enable' to turn ON group privacy for @%s (it will only receive commands and replies), or 'disable' to let it receive all group messages.", username)
|
||||
case botFatherCmdSetLogin:
|
||||
return telegramLoginConfigurationPrompt(username)
|
||||
default:
|
||||
return "Send the new value, or /cancel."
|
||||
}
|
||||
|
|
@ -445,6 +459,61 @@ func (s *Service) handleChooseBot(ctx context.Context, state domain.BotChatState
|
|||
}
|
||||
head := fmt.Sprintf("Token for @%s has been revoked. The old token will stop working immediately. New token:\n", chosen.Username)
|
||||
return tokenReply(head, token, "\n\nKeep your token secure and store it safely, it can be used by anyone to control your bot.")
|
||||
case botFatherCmdLoginInfo:
|
||||
defer s.clearState(ctx, state.UserID)
|
||||
if s.telegramLogin == nil {
|
||||
return botReply{Text: "Telegram Login is not enabled on this server."}
|
||||
}
|
||||
configuration, found, err := s.telegramLogin.ClientConfiguration(ctx, chosen.ID)
|
||||
if err != nil {
|
||||
s.log.Error("botfather: get telegram login configuration", zap.Int64("bot_user_id", chosen.ID), zap.Error(err))
|
||||
return internalReply()
|
||||
}
|
||||
if !found {
|
||||
return botReply{Text: fmt.Sprintf("Telegram Login is not configured for @%s. Use /setlogin to create it.", chosen.Username)}
|
||||
}
|
||||
return botReply{Text: formatTelegramLoginConfiguration(chosen.Username, configuration)}
|
||||
case botFatherCmdResetLogin:
|
||||
defer s.clearState(ctx, state.UserID)
|
||||
if s.telegramLogin == nil {
|
||||
return botReply{Text: "Telegram Login is not enabled on this server."}
|
||||
}
|
||||
credentials, err := s.telegramLogin.RotateClientSecret(ctx, chosen.ID)
|
||||
if errors.Is(err, domain.ErrTelegramLoginClientInvalid) {
|
||||
return botReply{Text: fmt.Sprintf("Telegram Login is not configured for @%s. Use /setlogin first.", chosen.Username)}
|
||||
}
|
||||
if err != nil {
|
||||
s.log.Error("botfather: rotate telegram login secret", zap.Int64("bot_user_id", chosen.ID), zap.Error(err))
|
||||
return internalReply()
|
||||
}
|
||||
head := fmt.Sprintf("The previous OIDC Client Secret for @%s is now invalid. Save this new secret; it will only be shown once:\n", chosen.Username)
|
||||
return tokenReply(head, credentials.Secret, "\n\nClient ID: "+credentials.Client.ClientID)
|
||||
case botFatherCmdSetLogin:
|
||||
if s.telegramLogin == nil {
|
||||
s.clearState(ctx, state.UserID)
|
||||
return botReply{Text: "Telegram Login is not enabled on this server."}
|
||||
}
|
||||
credentials, created, err := s.telegramLogin.EnsureClient(ctx, chosen.ID)
|
||||
if err != nil {
|
||||
s.log.Error("botfather: ensure telegram login client", zap.Int64("bot_user_id", chosen.ID), zap.Error(err))
|
||||
return internalReply()
|
||||
}
|
||||
state.Step = botFatherStepValue
|
||||
if state.Draft == nil {
|
||||
state.Draft = map[string]string{}
|
||||
}
|
||||
state.Draft[botFatherDraftBotID] = strconv.FormatInt(chosen.ID, 10)
|
||||
state.Draft[botFatherDraftBotUsername] = chosen.Username
|
||||
if err := s.bots.UpsertBotChatState(ctx, state); err != nil {
|
||||
s.log.Error("botfather: save telegram login state", zap.Int64("user_id", state.UserID), zap.Error(err))
|
||||
return internalReply()
|
||||
}
|
||||
prompt := telegramLoginConfigurationPrompt(chosen.Username)
|
||||
if !created {
|
||||
return botReply{Text: fmt.Sprintf("Telegram Login client %s is ready for @%s.\n\n%s", credentials.Client.ClientID, chosen.Username, prompt)}
|
||||
}
|
||||
head := fmt.Sprintf("Telegram Login is now enabled for @%s.\nClient ID: %s\nSave this Client Secret; it will only be shown once:\n", chosen.Username, credentials.Client.ClientID)
|
||||
return tokenReply(head, credentials.Secret, "\n\n"+prompt)
|
||||
case botFatherCmdSetName, botFatherCmdSetDescription, botFatherCmdSetAbout,
|
||||
botFatherCmdSetCommands, botFatherCmdSetInline, botFatherCmdSetInlineGeo,
|
||||
botFatherCmdSetJoinGroups, botFatherCmdSetPrivacy:
|
||||
|
|
@ -507,6 +576,8 @@ func (s *Service) handleSetValue(ctx context.Context, state domain.BotChatState,
|
|||
reply, err = s.applyToggle(ctx, botID, text, true)
|
||||
case botFatherCmdSetPrivacy:
|
||||
reply, err = s.applyToggle(ctx, botID, text, false)
|
||||
case botFatherCmdSetLogin:
|
||||
reply, err = s.applyTelegramLoginConfiguration(ctx, botID, username, text)
|
||||
default:
|
||||
s.clearState(ctx, state.UserID)
|
||||
return internalReply()
|
||||
|
|
@ -583,6 +654,156 @@ func (s *Service) applySetInlineGeo(ctx context.Context, botID int64, text strin
|
|||
return botReply{Text: fmt.Sprintf("Success! Inline location requests are now %s.", state)}, nil
|
||||
}
|
||||
|
||||
func telegramLoginConfigurationPrompt(username string) string {
|
||||
return fmt.Sprintf(`Send one configuration command for @%s:
|
||||
|
||||
add origin https://example.com
|
||||
add redirect https://example.com/auth/callback
|
||||
add ios com.example.app ABCDE12345 exampleapp://tglogin Example iOS App
|
||||
add android com.example.app AA:BB:...:FF exampleapp://telegram-login Example Android App
|
||||
remove origin https://example.com
|
||||
remove redirect https://example.com/auth/callback
|
||||
remove app 12
|
||||
algorithm RS256|ES256|EdDSA|ES256K
|
||||
enable
|
||||
disable
|
||||
|
||||
Origins authorize the JS SDK and legacy login_url buttons. Redirects are exact OIDC callbacks. Run /logininfo to inspect the result or /cancel to stop.`, username)
|
||||
}
|
||||
|
||||
func formatTelegramLoginConfiguration(username string, configuration telegramloginapp.ClientConfiguration) string {
|
||||
status := "disabled"
|
||||
if configuration.Client.Enabled {
|
||||
status = "enabled"
|
||||
}
|
||||
var out strings.Builder
|
||||
fmt.Fprintf(&out, "Telegram Login for @%s\nClient ID: %s\nStatus: %s\nSigning algorithm: %s\nSecret version: %d",
|
||||
username, configuration.Client.ClientID, status, configuration.Client.SigningAlgorithm, configuration.Client.SecretVersion)
|
||||
if len(configuration.AllowedURLs) == 0 {
|
||||
out.WriteString("\nAllowed URLs: none")
|
||||
} else {
|
||||
out.WriteString("\nAllowed URLs:")
|
||||
for _, allowed := range configuration.AllowedURLs {
|
||||
fmt.Fprintf(&out, "\n- %s %s", allowed.Kind, allowed.NormalizedURL)
|
||||
}
|
||||
}
|
||||
if len(configuration.NativeApps) > 0 {
|
||||
out.WriteString("\nNative apps:")
|
||||
for _, app := range configuration.NativeApps {
|
||||
fmt.Fprintf(&out, "\n- #%d %s %s [%s] -> %s (%s)", app.ID, app.Platform, app.ApplicationID, app.VerificationID, app.CallbackURI, app.VerifiedDisplayName)
|
||||
}
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func telegramLoginAllowedURLKind(raw string) (domain.TelegramLoginAllowedURLKind, bool) {
|
||||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||
case "origin":
|
||||
return domain.TelegramLoginAllowedWebOrigin, true
|
||||
case "redirect":
|
||||
return domain.TelegramLoginAllowedRedirectURI, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func telegramLoginSigningAlgorithm(raw string) (domain.TelegramLoginSigningAlgorithm, bool) {
|
||||
switch strings.ToUpper(strings.TrimSpace(raw)) {
|
||||
case "RS256":
|
||||
return domain.TelegramLoginSigningRS256, true
|
||||
case "ES256":
|
||||
return domain.TelegramLoginSigningES256, true
|
||||
case "EDDSA":
|
||||
return domain.TelegramLoginSigningEdDSA, true
|
||||
case "ES256K":
|
||||
return domain.TelegramLoginSigningES256K, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) applyTelegramLoginConfiguration(ctx context.Context, botID int64, username, text string) (botReply, error) {
|
||||
if s.telegramLogin == nil {
|
||||
return botReply{Text: "Telegram Login is not enabled on this server."}, domain.ErrTelegramLoginClientDisabled
|
||||
}
|
||||
fields := strings.Fields(strings.TrimSpace(text))
|
||||
if len(fields) == 1 {
|
||||
switch strings.ToLower(fields[0]) {
|
||||
case "enable":
|
||||
if err := s.telegramLogin.SetClientEnabled(ctx, botID, true); err != nil {
|
||||
return botReply{}, err
|
||||
}
|
||||
return botReply{Text: fmt.Sprintf("Telegram Login is enabled for @%s. Use /setlogin for another change or /logininfo to review it.", username)}, nil
|
||||
case "disable":
|
||||
if err := s.telegramLogin.SetClientEnabled(ctx, botID, false); err != nil {
|
||||
return botReply{}, err
|
||||
}
|
||||
return botReply{Text: fmt.Sprintf("Telegram Login is disabled for @%s. Pending requests can no longer be approved or exchanged.", username)}, nil
|
||||
}
|
||||
}
|
||||
if len(fields) == 2 && strings.EqualFold(fields[0], "algorithm") {
|
||||
algorithm, ok := telegramLoginSigningAlgorithm(fields[1])
|
||||
if !ok {
|
||||
return botReply{Text: "Unknown signing algorithm. Use RS256, ES256, EdDSA or ES256K, or /cancel."}, domain.ErrTelegramLoginClientInvalid
|
||||
}
|
||||
if _, err := s.telegramLogin.SetClientSigningAlgorithm(ctx, botID, algorithm); err != nil {
|
||||
if errors.Is(err, domain.ErrTelegramLoginClientInvalid) {
|
||||
return botReply{Text: fmt.Sprintf("%s is not available on this server because no active signing key is configured for it. Choose another algorithm or ask the operator to rotate the key ring.", algorithm)}, err
|
||||
}
|
||||
return botReply{}, err
|
||||
}
|
||||
return botReply{Text: fmt.Sprintf("Success! New ID tokens for @%s will use %s. EdDSA and ES256K accept only the openid scope.", username, algorithm)}, nil
|
||||
}
|
||||
if len(fields) == 3 && (strings.EqualFold(fields[0], "add") || strings.EqualFold(fields[0], "remove")) &&
|
||||
(strings.EqualFold(fields[1], "origin") || strings.EqualFold(fields[1], "redirect")) {
|
||||
kind, ok := telegramLoginAllowedURLKind(fields[1])
|
||||
if !ok {
|
||||
return botReply{Text: "URL kind must be origin or redirect. Try again or /cancel."}, domain.ErrTelegramLoginURLInvalid
|
||||
}
|
||||
if strings.EqualFold(fields[0], "add") {
|
||||
allowed, err := s.telegramLogin.AddAllowedURL(ctx, botID, kind, fields[2])
|
||||
if err != nil {
|
||||
return botReply{Text: "That URL is not allowed. Use an exact HTTPS URL without credentials, fragments or reserved OAuth query fields."}, err
|
||||
}
|
||||
return botReply{Text: fmt.Sprintf("Success! Added %s for @%s:\n%s", allowed.Kind, username, allowed.NormalizedURL)}, nil
|
||||
}
|
||||
deleted, err := s.telegramLogin.DeleteAllowedURL(ctx, botID, kind, fields[2])
|
||||
if err != nil {
|
||||
return botReply{Text: "That URL is invalid. Try again or /cancel."}, err
|
||||
}
|
||||
if !deleted {
|
||||
return botReply{Text: "That exact URL was not registered. Check /logininfo and try again."}, domain.ErrTelegramLoginURLInvalid
|
||||
}
|
||||
return botReply{Text: fmt.Sprintf("Success! Removed %s from @%s.", kind, username)}, nil
|
||||
}
|
||||
if len(fields) >= 6 && strings.EqualFold(fields[0], "add") && (strings.EqualFold(fields[1], "ios") || strings.EqualFold(fields[1], "android")) {
|
||||
platform := domain.TelegramLoginNativeIOS
|
||||
if strings.EqualFold(fields[1], "android") {
|
||||
platform = domain.TelegramLoginNativeAndroid
|
||||
}
|
||||
app, err := s.telegramLogin.AddNativeApp(ctx, botID, platform, fields[2], fields[3], fields[4], strings.Join(fields[5:], " "))
|
||||
if err != nil {
|
||||
return botReply{Text: "Invalid native app registration. iOS needs Bundle ID + 10-character Team ID; Android needs package name + SHA-256 signing fingerprint. Use an exact HTTPS callback or a custom scheme://host callback."}, err
|
||||
}
|
||||
return botReply{Text: fmt.Sprintf("Success! Registered native app #%d for @%s:\n%s %s -> %s", app.ID, username, app.Platform, app.ApplicationID, app.CallbackURI)}, nil
|
||||
}
|
||||
if len(fields) == 3 && strings.EqualFold(fields[0], "remove") && strings.EqualFold(fields[1], "app") {
|
||||
appID, err := strconv.ParseInt(fields[2], 10, 64)
|
||||
if err != nil || appID <= 0 {
|
||||
return botReply{Text: "Native app ID must be the positive number shown by /logininfo."}, domain.ErrTelegramLoginClientInvalid
|
||||
}
|
||||
deleted, err := s.telegramLogin.DeleteNativeApp(ctx, botID, appID)
|
||||
if err != nil {
|
||||
return botReply{}, err
|
||||
}
|
||||
if !deleted {
|
||||
return botReply{Text: "That native app was not registered for this bot. Check /logininfo."}, domain.ErrTelegramLoginClientInvalid
|
||||
}
|
||||
return botReply{Text: fmt.Sprintf("Success! Removed native app #%d from @%s.", appID, username)}, nil
|
||||
}
|
||||
return botReply{Text: telegramLoginConfigurationPrompt(username)}, domain.ErrTelegramLoginRequestInvalid
|
||||
}
|
||||
|
||||
// applyToggle 解析 enable/disable 并设置 joingroups(join=true)或 privacy(join=false)。
|
||||
func (s *Service) applyToggle(ctx context.Context, botID int64, text string, join bool) (botReply, error) {
|
||||
var on bool
|
||||
|
|
|
|||
100
internal/app/bots/botfather_login_test.go
Normal file
100
internal/app/bots/botfather_login_test.go
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
package bots
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
telegramloginapp "telesrv/internal/app/telegramlogin"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
func newBotFatherTelegramLoginService(t *testing.T) *telegramloginapp.Service {
|
||||
t.Helper()
|
||||
sealKey := make([]byte, 32)
|
||||
sealKey[0] = 1
|
||||
sealer, err := telegramloginapp.NewCodeSealer("test", map[string][]byte{"test": sealKey})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pepper := make([]byte, 32)
|
||||
pepper[0] = 2
|
||||
service, err := telegramloginapp.NewService(memory.NewTelegramLoginStore(nil), sealer, telegramloginapp.Config{
|
||||
Issuer: "http://localhost:2404", AppScheme: "telesrv", AllowLoopbackHTTP: true,
|
||||
ClientSecretPepper: pepper, Now: func() time.Time { return time.Unix(1_780_000_000, 0).UTC() },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return service
|
||||
}
|
||||
|
||||
func TestBotFatherTelegramLoginConfigurationFlow(t *testing.T) {
|
||||
svc, users, _, messages := newTestService(t)
|
||||
svc.telegramLogin = newBotFatherTelegramLoginService(t)
|
||||
owner := newOwner(t, users, "+1090")
|
||||
bot, _, err := svc.CreateBot(context.Background(), owner.ID, "Login Demo", "login_demo_bot")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if reply := sendToBotFather(t, svc, messages, owner, "/setlogin"); !strings.Contains(reply, "Choose a bot") {
|
||||
t.Fatalf("/setlogin reply = %q", reply)
|
||||
}
|
||||
created := sendToBotFather(t, svc, messages, owner, "@login_demo_bot")
|
||||
if !strings.Contains(created, "Client ID: "+strconv.FormatInt(bot.ID, 10)) || !strings.Contains(created, "only be shown once") {
|
||||
t.Fatalf("create login reply = %q", created)
|
||||
}
|
||||
secretMarker := "only be shown once:\n"
|
||||
secret := strings.SplitN(strings.SplitN(created, secretMarker, 2)[1], "\n", 2)[0]
|
||||
if len(secret) < 32 {
|
||||
t.Fatalf("client secret is unexpectedly short: %q", secret)
|
||||
}
|
||||
if reply := sendToBotFather(t, svc, messages, owner, "add origin http://localhost:3000"); !strings.Contains(reply, "Success!") {
|
||||
t.Fatalf("add origin reply = %q", reply)
|
||||
}
|
||||
|
||||
sendToBotFather(t, svc, messages, owner, "/setlogin")
|
||||
sendToBotFather(t, svc, messages, owner, "login_demo_bot")
|
||||
if reply := sendToBotFather(t, svc, messages, owner, "add redirect http://localhost:3000/auth/callback"); !strings.Contains(reply, "Success!") {
|
||||
t.Fatalf("add redirect reply = %q", reply)
|
||||
}
|
||||
|
||||
sendToBotFather(t, svc, messages, owner, "/setlogin")
|
||||
sendToBotFather(t, svc, messages, owner, "login_demo_bot")
|
||||
if reply := sendToBotFather(t, svc, messages, owner, "algorithm ES256"); !strings.Contains(reply, "ES256") {
|
||||
t.Fatalf("algorithm reply = %q", reply)
|
||||
}
|
||||
|
||||
sendToBotFather(t, svc, messages, owner, "/setlogin")
|
||||
sendToBotFather(t, svc, messages, owner, "login_demo_bot")
|
||||
if reply := sendToBotFather(t, svc, messages, owner, "add ios dev.bedolaga.demo ABCDE12345 bedolaga://telegram-login Bedolaga iOS Demo"); !strings.Contains(reply, "Registered native app #") {
|
||||
t.Fatalf("add iOS app reply = %q", reply)
|
||||
}
|
||||
|
||||
sendToBotFather(t, svc, messages, owner, "/setlogin")
|
||||
sendToBotFather(t, svc, messages, owner, "login_demo_bot")
|
||||
fingerprint := strings.Repeat("A", 64)
|
||||
if reply := sendToBotFather(t, svc, messages, owner, "add android dev.bedolaga.demo "+fingerprint+" bedolaga://android-login Bedolaga Android Demo"); !strings.Contains(reply, "Registered native app #") {
|
||||
t.Fatalf("add Android app reply = %q", reply)
|
||||
}
|
||||
|
||||
sendToBotFather(t, svc, messages, owner, "/logininfo")
|
||||
info := sendToBotFather(t, svc, messages, owner, "login_demo_bot")
|
||||
for _, want := range []string{"Signing algorithm: ES256", "web_origin http://localhost:3000", "redirect_uri http://localhost:3000/auth/callback", "dev.bedolaga.demo", "Bedolaga iOS Demo", "Bedolaga Android Demo"} {
|
||||
if !strings.Contains(info, want) {
|
||||
t.Fatalf("login info = %q, missing %q", info, want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(info, secret) {
|
||||
t.Fatal("/logininfo leaked the one-time client secret")
|
||||
}
|
||||
|
||||
sendToBotFather(t, svc, messages, owner, "/resetloginsecret")
|
||||
rotated := sendToBotFather(t, svc, messages, owner, "login_demo_bot")
|
||||
if !strings.Contains(rotated, "previous OIDC Client Secret") || strings.Contains(rotated, secret) {
|
||||
t.Fatalf("rotate reply = %q", rotated)
|
||||
}
|
||||
}
|
||||
|
|
@ -16,6 +16,7 @@ import (
|
|||
|
||||
"go.uber.org/zap"
|
||||
|
||||
telegramloginapp "telesrv/internal/app/telegramlogin"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/links"
|
||||
"telesrv/internal/store"
|
||||
|
|
@ -82,6 +83,7 @@ type Service struct {
|
|||
stickers stickerSetCreator
|
||||
installer userStickerSetInstaller
|
||||
aiChat aiChatGenerator
|
||||
telegramLogin *telegramloginapp.Service
|
||||
hooks RouterHooks
|
||||
textDrafts TextDraftPusher
|
||||
userCache store.UserCache
|
||||
|
|
@ -175,6 +177,16 @@ func WithAIChatGenerator(g aiChatGenerator) Option {
|
|||
}
|
||||
}
|
||||
|
||||
// WithTelegramLogin injects the OIDC application service used by BotFather.
|
||||
// BotFather never writes the login tables directly.
|
||||
func WithTelegramLogin(login *telegramloginapp.Service) Option {
|
||||
return func(s *Service) {
|
||||
if login != nil {
|
||||
s.telegramLogin = login
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithAIChatStreamThrottle 调整 @ChatBot 流式草稿推送的最小时间间隔(测试用)。
|
||||
func WithAIChatStreamThrottle(d time.Duration) Option {
|
||||
return func(s *Service) {
|
||||
|
|
|
|||
131
internal/app/telegramlogin/crypto.go
Normal file
131
internal/app/telegramlogin/crypto.go
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
package telegramlogin
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
const opaqueTokenBytes = 32
|
||||
|
||||
func GenerateOpaqueToken() (string, error) {
|
||||
raw := make([]byte, opaqueTokenBytes)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", fmt.Errorf("generate opaque token: %w", err)
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(raw), nil
|
||||
}
|
||||
|
||||
func HashOpaqueToken(token string) []byte {
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
return sum[:]
|
||||
}
|
||||
|
||||
func HashClientSecret(pepper []byte, secret string) ([]byte, error) {
|
||||
if len(pepper) < 32 || secret == "" {
|
||||
return nil, domain.ErrTelegramLoginSecretInvalid
|
||||
}
|
||||
mac := hmac.New(sha256.New, pepper)
|
||||
_, _ = mac.Write([]byte(secret))
|
||||
return mac.Sum(nil), nil
|
||||
}
|
||||
|
||||
func VerifyClientSecret(pepper []byte, secret string, expected []byte) bool {
|
||||
actual, err := HashClientSecret(pepper, secret)
|
||||
if err != nil || len(expected) != sha256.Size {
|
||||
return false
|
||||
}
|
||||
return subtle.ConstantTimeCompare(actual, expected) == 1
|
||||
}
|
||||
|
||||
func PKCEChallenge(verifier string) (string, error) {
|
||||
if len(verifier) < 43 || len(verifier) > 128 {
|
||||
return "", domain.ErrTelegramLoginPKCEInvalid
|
||||
}
|
||||
for i := 0; i < len(verifier); i++ {
|
||||
c := verifier[i]
|
||||
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '.' || c == '_' || c == '~') {
|
||||
return "", domain.ErrTelegramLoginPKCEInvalid
|
||||
}
|
||||
}
|
||||
sum := sha256.Sum256([]byte(verifier))
|
||||
return base64.RawURLEncoding.EncodeToString(sum[:]), nil
|
||||
}
|
||||
|
||||
func ValidatePKCEChallenge(challenge, method string) error {
|
||||
if method != "S256" || len(challenge) < 43 || len(challenge) > 128 {
|
||||
return domain.ErrTelegramLoginPKCEInvalid
|
||||
}
|
||||
for i := 0; i < len(challenge); i++ {
|
||||
c := challenge[i]
|
||||
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_') {
|
||||
return domain.ErrTelegramLoginPKCEInvalid
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type CodeSealer struct {
|
||||
activeKeyID string
|
||||
keys map[string]cipher.AEAD
|
||||
}
|
||||
|
||||
func NewCodeSealer(activeKeyID string, rawKeys map[string][]byte) (*CodeSealer, error) {
|
||||
if activeKeyID == "" || len(rawKeys) == 0 {
|
||||
return nil, errors.New("telegram login code seal key ring is empty")
|
||||
}
|
||||
keys := make(map[string]cipher.AEAD, len(rawKeys))
|
||||
for keyID, raw := range rawKeys {
|
||||
if keyID == "" || len(raw) != 32 {
|
||||
return nil, fmt.Errorf("invalid telegram login code seal key %q", keyID)
|
||||
}
|
||||
block, err := aes.NewCipher(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create telegram login code seal key %q: %w", keyID, err)
|
||||
}
|
||||
aead, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create telegram login code sealer %q: %w", keyID, err)
|
||||
}
|
||||
keys[keyID] = aead
|
||||
}
|
||||
if _, ok := keys[activeKeyID]; !ok {
|
||||
return nil, fmt.Errorf("active telegram login code seal key %q not found", activeKeyID)
|
||||
}
|
||||
return &CodeSealer{activeKeyID: activeKeyID, keys: keys}, nil
|
||||
}
|
||||
|
||||
func (s *CodeSealer) Seal(plaintext string, aad []byte) (sealed, nonce []byte, keyID string, err error) {
|
||||
if s == nil || plaintext == "" {
|
||||
return nil, nil, "", domain.ErrTelegramLoginCodeInvalid
|
||||
}
|
||||
aead := s.keys[s.activeKeyID]
|
||||
nonce = make([]byte, aead.NonceSize())
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return nil, nil, "", fmt.Errorf("generate telegram login code nonce: %w", err)
|
||||
}
|
||||
return aead.Seal(nil, nonce, []byte(plaintext), aad), nonce, s.activeKeyID, nil
|
||||
}
|
||||
|
||||
func (s *CodeSealer) Open(sealed, nonce []byte, keyID string, aad []byte) (string, error) {
|
||||
if s == nil {
|
||||
return "", domain.ErrTelegramLoginCodeInvalid
|
||||
}
|
||||
aead, ok := s.keys[keyID]
|
||||
if !ok || len(nonce) != aead.NonceSize() {
|
||||
return "", domain.ErrTelegramLoginCodeInvalid
|
||||
}
|
||||
plaintext, err := aead.Open(nil, nonce, sealed, aad)
|
||||
if err != nil || len(plaintext) == 0 {
|
||||
return "", domain.ErrTelegramLoginCodeInvalid
|
||||
}
|
||||
return string(plaintext), nil
|
||||
}
|
||||
406
internal/app/telegramlogin/jose.go
Normal file
406
internal/app/telegramlogin/jose.go
Normal file
|
|
@ -0,0 +1,406 @@
|
|||
package telegramlogin
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/ecdsa"
|
||||
"crypto/ed25519"
|
||||
"crypto/elliptic"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/lestrrat-go/jwx/v3/jwa"
|
||||
"github.com/lestrrat-go/jwx/v3/jwk"
|
||||
"github.com/lestrrat-go/jwx/v3/jwt"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
const defaultIDTokenTTL = time.Hour
|
||||
|
||||
type SigningKeyMaterial struct {
|
||||
Algorithm domain.TelegramLoginSigningAlgorithm
|
||||
KeyID string
|
||||
PrivateKey any
|
||||
Active bool
|
||||
PublishUntil time.Time
|
||||
}
|
||||
|
||||
type signingKey struct {
|
||||
algorithm domain.TelegramLoginSigningAlgorithm
|
||||
jwaAlgorithm jwa.SignatureAlgorithm
|
||||
keyID string
|
||||
private jwk.Key
|
||||
public jwk.Key
|
||||
active bool
|
||||
publishUntil time.Time
|
||||
}
|
||||
|
||||
// SigningKeyRing owns no mutable crypto state. Rotation is performed by
|
||||
// constructing a new ring containing the new active key and old public keys
|
||||
// with a PublishUntil at least as long as the maximum ID-token lifetime.
|
||||
type SigningKeyRing struct {
|
||||
keys []signingKey
|
||||
active map[domain.TelegramLoginSigningAlgorithm]signingKey
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewSigningKeyRing(materials []SigningKeyMaterial, now func() time.Time) (*SigningKeyRing, error) {
|
||||
if len(materials) == 0 {
|
||||
return nil, errors.New("telegram login signing key ring is empty")
|
||||
}
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
ring := &SigningKeyRing{
|
||||
keys: make([]signingKey, 0, len(materials)),
|
||||
active: make(map[domain.TelegramLoginSigningAlgorithm]signingKey),
|
||||
now: now,
|
||||
}
|
||||
seenKeyIDs := make(map[string]struct{}, len(materials))
|
||||
for _, material := range materials {
|
||||
key, err := importSigningKey(material)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, duplicate := seenKeyIDs[key.keyID]; duplicate {
|
||||
return nil, fmt.Errorf("duplicate telegram login signing kid %q", key.keyID)
|
||||
}
|
||||
seenKeyIDs[key.keyID] = struct{}{}
|
||||
if key.active {
|
||||
if _, duplicate := ring.active[key.algorithm]; duplicate {
|
||||
return nil, fmt.Errorf("multiple active telegram login signing keys for %s", key.algorithm)
|
||||
}
|
||||
ring.active[key.algorithm] = key
|
||||
}
|
||||
ring.keys = append(ring.keys, key)
|
||||
}
|
||||
if len(ring.active) == 0 {
|
||||
return nil, errors.New("telegram login signing key ring has no active key")
|
||||
}
|
||||
return ring, nil
|
||||
}
|
||||
|
||||
func importSigningKey(material SigningKeyMaterial) (signingKey, error) {
|
||||
if !material.Algorithm.Valid() || material.PrivateKey == nil {
|
||||
return signingKey{}, fmt.Errorf("invalid telegram login signing key material")
|
||||
}
|
||||
if material.Algorithm == domain.TelegramLoginSigningES256K && !telegramLoginES256KEnabled {
|
||||
return signingKey{}, errors.New("telegram login ES256K requires a build with -tags jwx_es256k")
|
||||
}
|
||||
if err := validateRawSigningKey(material.Algorithm, material.PrivateKey); err != nil {
|
||||
return signingKey{}, err
|
||||
}
|
||||
privateKey, err := jwk.Import(material.PrivateKey)
|
||||
if err != nil {
|
||||
return signingKey{}, fmt.Errorf("import telegram login %s private key: %w", material.Algorithm, err)
|
||||
}
|
||||
if err := privateKey.Validate(); err != nil {
|
||||
return signingKey{}, fmt.Errorf("validate telegram login %s private JWK: %w", material.Algorithm, err)
|
||||
}
|
||||
publicKey, err := privateKey.PublicKey()
|
||||
if err != nil {
|
||||
return signingKey{}, fmt.Errorf("derive telegram login %s public JWK: %w", material.Algorithm, err)
|
||||
}
|
||||
thumbprint, err := publicKey.Thumbprint(crypto.SHA256)
|
||||
if err != nil {
|
||||
return signingKey{}, fmt.Errorf("thumbprint telegram login %s public JWK: %w", material.Algorithm, err)
|
||||
}
|
||||
keyID := strings.TrimSpace(material.KeyID)
|
||||
if keyID == "" {
|
||||
keyID = base64.RawURLEncoding.EncodeToString(thumbprint)
|
||||
}
|
||||
if len(keyID) > 128 || strings.IndexFunc(keyID, func(r rune) bool { return r <= 0x20 || r == 0x7f }) >= 0 {
|
||||
return signingKey{}, fmt.Errorf("invalid telegram login signing kid")
|
||||
}
|
||||
jwaAlgorithm, err := telegramLoginJWA(material.Algorithm)
|
||||
if err != nil {
|
||||
return signingKey{}, err
|
||||
}
|
||||
for _, key := range []jwk.Key{privateKey, publicKey} {
|
||||
if err := key.Set(jwk.KeyIDKey, keyID); err != nil {
|
||||
return signingKey{}, fmt.Errorf("set telegram login signing kid: %w", err)
|
||||
}
|
||||
if err := key.Set(jwk.AlgorithmKey, jwaAlgorithm); err != nil {
|
||||
return signingKey{}, fmt.Errorf("set telegram login signing algorithm: %w", err)
|
||||
}
|
||||
if err := key.Set(jwk.KeyUsageKey, "sig"); err != nil {
|
||||
return signingKey{}, fmt.Errorf("set telegram login signing use: %w", err)
|
||||
}
|
||||
}
|
||||
return signingKey{
|
||||
algorithm: material.Algorithm, jwaAlgorithm: jwaAlgorithm, keyID: keyID,
|
||||
private: privateKey, public: publicKey, active: material.Active,
|
||||
publishUntil: material.PublishUntil.UTC(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func validateRawSigningKey(algorithm domain.TelegramLoginSigningAlgorithm, raw any) error {
|
||||
switch algorithm {
|
||||
case domain.TelegramLoginSigningRS256:
|
||||
key, ok := rsaPrivateKey(raw)
|
||||
if !ok || key.N == nil || key.N.BitLen() < 2048 || key.E < 3 {
|
||||
return errors.New("telegram login RS256 requires an RSA private key of at least 2048 bits")
|
||||
}
|
||||
if err := key.Validate(); err != nil {
|
||||
return fmt.Errorf("validate telegram login RSA private key: %w", err)
|
||||
}
|
||||
case domain.TelegramLoginSigningES256:
|
||||
key, ok := ecdsaPrivateKey(raw)
|
||||
if !ok || key.Curve != elliptic.P256() || key.D == nil || key.X == nil || key.Y == nil {
|
||||
return errors.New("telegram login ES256 requires a P-256 ECDSA private key")
|
||||
}
|
||||
case domain.TelegramLoginSigningEdDSA:
|
||||
key, ok := raw.(ed25519.PrivateKey)
|
||||
if !ok || len(key) != ed25519.PrivateKeySize {
|
||||
return errors.New("telegram login EdDSA requires an Ed25519 private key")
|
||||
}
|
||||
case domain.TelegramLoginSigningES256K:
|
||||
key, ok := ecdsaPrivateKey(raw)
|
||||
if !ok || key.Curve == nil || key.Curve.Params() == nil ||
|
||||
!strings.EqualFold(key.Curve.Params().Name, "secp256k1") || key.D == nil || key.X == nil || key.Y == nil {
|
||||
return errors.New("telegram login ES256K requires a secp256k1 ECDSA private key")
|
||||
}
|
||||
default:
|
||||
return domain.ErrTelegramLoginClientInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func rsaPrivateKey(raw any) (*rsa.PrivateKey, bool) {
|
||||
switch key := raw.(type) {
|
||||
case *rsa.PrivateKey:
|
||||
return key, key != nil
|
||||
case rsa.PrivateKey:
|
||||
return &key, true
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
func ecdsaPrivateKey(raw any) (*ecdsa.PrivateKey, bool) {
|
||||
switch key := raw.(type) {
|
||||
case *ecdsa.PrivateKey:
|
||||
return key, key != nil
|
||||
case ecdsa.PrivateKey:
|
||||
return &key, true
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
func telegramLoginJWA(algorithm domain.TelegramLoginSigningAlgorithm) (jwa.SignatureAlgorithm, error) {
|
||||
switch algorithm {
|
||||
case domain.TelegramLoginSigningRS256:
|
||||
return jwa.RS256(), nil
|
||||
case domain.TelegramLoginSigningES256:
|
||||
return jwa.ES256(), nil
|
||||
case domain.TelegramLoginSigningEdDSA:
|
||||
return jwa.EdDSA(), nil
|
||||
case domain.TelegramLoginSigningES256K:
|
||||
if telegramLoginES256KEnabled {
|
||||
return jwa.ES256K(), nil
|
||||
}
|
||||
return jwa.EmptySignatureAlgorithm(), errors.New("telegram login ES256K is disabled in this build")
|
||||
default:
|
||||
return jwa.EmptySignatureAlgorithm(), domain.ErrTelegramLoginClientInvalid
|
||||
}
|
||||
}
|
||||
|
||||
func (r *SigningKeyRing) SupportedAlgorithms() []string {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
ordered := make([]string, 0, len(r.active))
|
||||
for _, algorithm := range []domain.TelegramLoginSigningAlgorithm{
|
||||
domain.TelegramLoginSigningRS256,
|
||||
domain.TelegramLoginSigningES256,
|
||||
domain.TelegramLoginSigningEdDSA,
|
||||
domain.TelegramLoginSigningES256K,
|
||||
} {
|
||||
if _, ok := r.active[algorithm]; ok {
|
||||
ordered = append(ordered, string(algorithm))
|
||||
}
|
||||
}
|
||||
return ordered
|
||||
}
|
||||
|
||||
// ActiveAlgorithms returns the algorithms that can sign new tokens on this
|
||||
// instance. Callers use it to prevent durable client configuration from
|
||||
// selecting an algorithm without an active private key.
|
||||
func (r *SigningKeyRing) ActiveAlgorithms() []domain.TelegramLoginSigningAlgorithm {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
ordered := make([]domain.TelegramLoginSigningAlgorithm, 0, len(r.active))
|
||||
for _, algorithm := range []domain.TelegramLoginSigningAlgorithm{
|
||||
domain.TelegramLoginSigningRS256,
|
||||
domain.TelegramLoginSigningES256,
|
||||
domain.TelegramLoginSigningEdDSA,
|
||||
domain.TelegramLoginSigningES256K,
|
||||
} {
|
||||
if _, ok := r.active[algorithm]; ok {
|
||||
ordered = append(ordered, algorithm)
|
||||
}
|
||||
}
|
||||
return ordered
|
||||
}
|
||||
|
||||
func (r *SigningKeyRing) JWKS() ([]byte, string, error) {
|
||||
if r == nil {
|
||||
return nil, "", errors.New("telegram login signing key ring is nil")
|
||||
}
|
||||
now := r.now().UTC()
|
||||
set := jwk.NewSet()
|
||||
for _, key := range r.keys {
|
||||
if !key.active && (key.publishUntil.IsZero() || !now.Before(key.publishUntil)) {
|
||||
continue
|
||||
}
|
||||
clone, err := key.public.Clone()
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("clone telegram login public JWK: %w", err)
|
||||
}
|
||||
if err := set.AddKey(clone); err != nil {
|
||||
return nil, "", fmt.Errorf("add telegram login public JWK: %w", err)
|
||||
}
|
||||
}
|
||||
body, err := json.Marshal(set)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("marshal telegram login JWKS: %w", err)
|
||||
}
|
||||
sum := sha256.Sum256(body)
|
||||
return body, `"` + base64.RawURLEncoding.EncodeToString(sum[:]) + `"`, nil
|
||||
}
|
||||
|
||||
func (r *SigningKeyRing) sign(algorithm domain.TelegramLoginSigningAlgorithm, token jwt.Token) (string, error) {
|
||||
if r == nil || token == nil {
|
||||
return "", errors.New("telegram login ID token signer is unavailable")
|
||||
}
|
||||
key, ok := r.active[algorithm]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("no active telegram login signing key for %s", algorithm)
|
||||
}
|
||||
signed, err := jwt.Sign(token, jwt.WithKey(key.jwaAlgorithm, key.private))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("sign telegram login ID token with %s: %w", algorithm, err)
|
||||
}
|
||||
return string(signed), nil
|
||||
}
|
||||
|
||||
type IDTokenIssuerConfig struct {
|
||||
Issuer string
|
||||
TTL time.Duration
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
type IDTokenIssuer struct {
|
||||
issuer string
|
||||
ttl time.Duration
|
||||
now func() time.Time
|
||||
keys *SigningKeyRing
|
||||
}
|
||||
|
||||
func (i *IDTokenIssuer) Issuer() string {
|
||||
if i == nil {
|
||||
return ""
|
||||
}
|
||||
return i.issuer
|
||||
}
|
||||
|
||||
func (i *IDTokenIssuer) TTL() time.Duration {
|
||||
if i == nil {
|
||||
return 0
|
||||
}
|
||||
return i.ttl
|
||||
}
|
||||
|
||||
func (i *IDTokenIssuer) SupportedAlgorithms() []string {
|
||||
if i == nil {
|
||||
return nil
|
||||
}
|
||||
return i.keys.SupportedAlgorithms()
|
||||
}
|
||||
|
||||
func (i *IDTokenIssuer) JWKS() ([]byte, string, error) {
|
||||
if i == nil {
|
||||
return nil, "", errors.New("telegram login ID token issuer is nil")
|
||||
}
|
||||
return i.keys.JWKS()
|
||||
}
|
||||
|
||||
func NewIDTokenIssuer(keys *SigningKeyRing, cfg IDTokenIssuerConfig) (*IDTokenIssuer, error) {
|
||||
if keys == nil {
|
||||
return nil, errors.New("telegram login signing key ring is required")
|
||||
}
|
||||
issuer, err := NormalizeWebOrigin(cfg.Issuer, true)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("telegram login ID token issuer: %w", err)
|
||||
}
|
||||
if cfg.TTL == 0 {
|
||||
cfg.TTL = defaultIDTokenTTL
|
||||
}
|
||||
if cfg.TTL < time.Minute || cfg.TTL > 24*time.Hour {
|
||||
return nil, errors.New("telegram login ID token TTL is outside the bounded range")
|
||||
}
|
||||
if cfg.Now == nil {
|
||||
cfg.Now = time.Now
|
||||
}
|
||||
return &IDTokenIssuer{issuer: issuer, ttl: cfg.TTL, now: cfg.Now, keys: keys}, nil
|
||||
}
|
||||
|
||||
func (i *IDTokenIssuer) Issue(request domain.TelegramLoginRequest) (string, error) {
|
||||
if i == nil || request.Status != domain.TelegramLoginRequestApproved || request.AuthorizedUserID <= 0 ||
|
||||
request.ClientID == "" || request.ApprovedAt.IsZero() {
|
||||
return "", domain.ErrTelegramLoginRequestInvalid
|
||||
}
|
||||
if err := domain.ValidateTelegramLoginScopes(request.Scopes, request.SigningAlgorithm); err != nil {
|
||||
return "", err
|
||||
}
|
||||
identity := domain.TelegramLoginIdentitySnapshot{
|
||||
UserID: request.AuthorizedUserID, Name: request.ProfileName, GivenName: request.GivenName,
|
||||
FamilyName: request.FamilyName, PreferredUsername: request.PreferredUsername,
|
||||
Picture: request.Picture, PhoneNumber: request.PhoneNumber,
|
||||
}
|
||||
identity, err := identity.Sanitized(request.Requests(domain.TelegramLoginScopeProfile), request.PhoneShared)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
now := i.now().UTC()
|
||||
builder := jwt.NewBuilder().
|
||||
Issuer(i.issuer).
|
||||
Audience([]string{request.ClientID}).
|
||||
Subject(fmt.Sprintf("%d", identity.UserID)).
|
||||
IssuedAt(now).
|
||||
Expiration(now.Add(i.ttl))
|
||||
if request.Nonce != "" {
|
||||
builder.Claim("nonce", request.Nonce)
|
||||
}
|
||||
if request.Requests(domain.TelegramLoginScopeProfile) {
|
||||
builder.Claim("id", identity.UserID).
|
||||
Claim("name", identity.Name).
|
||||
Claim("given_name", identity.GivenName)
|
||||
if identity.FamilyName != "" {
|
||||
builder.Claim("family_name", identity.FamilyName)
|
||||
}
|
||||
if identity.PreferredUsername != "" {
|
||||
builder.Claim("preferred_username", identity.PreferredUsername)
|
||||
}
|
||||
if identity.Picture != "" {
|
||||
builder.Claim("picture", identity.Picture)
|
||||
}
|
||||
}
|
||||
if request.Requests(domain.TelegramLoginScopePhone) && request.PhoneShared {
|
||||
builder.Claim("phone_number", identity.PhoneNumber).
|
||||
Claim("phone_number_verified", true)
|
||||
}
|
||||
token, err := builder.Build()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("build telegram login ID token: %w", err)
|
||||
}
|
||||
return i.keys.sign(request.SigningAlgorithm, token)
|
||||
}
|
||||
5
internal/app/telegramlogin/jose_es256k_disabled.go
Normal file
5
internal/app/telegramlogin/jose_es256k_disabled.go
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
//go:build !jwx_es256k
|
||||
|
||||
package telegramlogin
|
||||
|
||||
const telegramLoginES256KEnabled = false
|
||||
17
internal/app/telegramlogin/jose_es256k_disabled_test.go
Normal file
17
internal/app/telegramlogin/jose_es256k_disabled_test.go
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
//go:build !jwx_es256k
|
||||
|
||||
package telegramlogin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestES256KFailsClosedWithoutBuildTag(t *testing.T) {
|
||||
if _, err := NewSigningKeyRing([]SigningKeyMaterial{{
|
||||
Algorithm: domain.TelegramLoginSigningES256K, PrivateKey: struct{}{}, Active: true,
|
||||
}}, nil); err == nil {
|
||||
t.Fatal("ES256K configuration unexpectedly accepted without jwx_es256k")
|
||||
}
|
||||
}
|
||||
5
internal/app/telegramlogin/jose_es256k_enabled.go
Normal file
5
internal/app/telegramlogin/jose_es256k_enabled.go
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
//go:build jwx_es256k
|
||||
|
||||
package telegramlogin
|
||||
|
||||
const telegramLoginES256KEnabled = true
|
||||
57
internal/app/telegramlogin/jose_es256k_enabled_test.go
Normal file
57
internal/app/telegramlogin/jose_es256k_enabled_test.go
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
//go:build jwx_es256k
|
||||
|
||||
package telegramlogin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/decred/dcrd/dcrec/secp256k1/v4"
|
||||
"github.com/lestrrat-go/jwx/v3/jwk"
|
||||
"github.com/lestrrat-go/jwx/v3/jwt"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestES256KIDTokenRoundTripWithBuildTag(t *testing.T) {
|
||||
raw, err := secp256k1.GeneratePrivateKey()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Date(2026, 7, 20, 10, 0, 0, 0, time.UTC)
|
||||
ring, err := NewSigningKeyRing([]SigningKeyMaterial{{
|
||||
Algorithm: domain.TelegramLoginSigningES256K,
|
||||
KeyID: "secp256k1-active",
|
||||
PrivateKey: raw.ToECDSA(),
|
||||
Active: true,
|
||||
}}, func() time.Time { return now })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
issuer, err := NewIDTokenIssuer(ring, IDTokenIssuerConfig{
|
||||
Issuer: "https://oauth.telesrv.test", Now: func() time.Time { return now },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
signed, err := issuer.Issue(domain.TelegramLoginRequest{
|
||||
ClientID: "9001", SigningAlgorithm: domain.TelegramLoginSigningES256K,
|
||||
Scopes: []domain.TelegramLoginScope{domain.TelegramLoginScopeOpenID},
|
||||
Status: domain.TelegramLoginRequestApproved, AuthorizedUserID: 42,
|
||||
ApprovedAt: now.Add(-time.Minute),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, _, err := ring.JWKS()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
set, err := jwk.Parse(body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := jwt.Parse([]byte(signed), jwt.WithKeySet(set), jwt.WithValidate(false)); err != nil {
|
||||
t.Fatalf("verify ES256K token: %v", err)
|
||||
}
|
||||
}
|
||||
188
internal/app/telegramlogin/jose_test.go
Normal file
188
internal/app/telegramlogin/jose_test.go
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
package telegramlogin
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/ed25519"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/lestrrat-go/jwx/v3/jwk"
|
||||
"github.com/lestrrat-go/jwx/v3/jwt"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func telegramLoginTestSigningKeys(t *testing.T, now *time.Time) *SigningKeyRing {
|
||||
t.Helper()
|
||||
oldRSA, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
activeRSA, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
es256, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, ed25519Key, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ring, err := NewSigningKeyRing([]SigningKeyMaterial{
|
||||
{Algorithm: domain.TelegramLoginSigningRS256, KeyID: "rsa-old", PrivateKey: oldRSA, PublishUntil: now.Add(2 * time.Hour)},
|
||||
{Algorithm: domain.TelegramLoginSigningRS256, KeyID: "rsa-active", PrivateKey: activeRSA, Active: true},
|
||||
{Algorithm: domain.TelegramLoginSigningES256, KeyID: "p256-active", PrivateKey: es256, Active: true},
|
||||
{Algorithm: domain.TelegramLoginSigningEdDSA, KeyID: "ed25519-active", PrivateKey: ed25519Key, Active: true},
|
||||
}, func() time.Time { return *now })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return ring
|
||||
}
|
||||
|
||||
func TestSigningKeyRingRotationAndAlgorithms(t *testing.T) {
|
||||
now := time.Date(2026, 7, 20, 10, 0, 0, 0, time.UTC)
|
||||
ring := telegramLoginTestSigningKeys(t, &now)
|
||||
if got := ring.SupportedAlgorithms(); len(got) != 3 || got[0] != "RS256" || got[1] != "ES256" || got[2] != "EdDSA" {
|
||||
t.Fatalf("SupportedAlgorithms = %#v", got)
|
||||
}
|
||||
body, etag, err := ring.JWKS()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
set, err := jwk.Parse(body)
|
||||
if err != nil {
|
||||
t.Fatalf("parse JWKS: %v", err)
|
||||
}
|
||||
if set.Len() != 4 || etag == "" {
|
||||
t.Fatalf("JWKS len=%d etag=%q body=%s", set.Len(), etag, body)
|
||||
}
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal(body, &decoded); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < set.Len(); i++ {
|
||||
key, _ := set.Key(i)
|
||||
if key.Has("d") || key.Has("p") || key.Has("q") {
|
||||
t.Fatalf("JWKS leaked private key material: %s", body)
|
||||
}
|
||||
}
|
||||
now = now.Add(3 * time.Hour)
|
||||
body, _, err = ring.JWKS()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
set, err = jwk.Parse(body)
|
||||
if err != nil || set.Len() != 3 {
|
||||
t.Fatalf("JWKS after retirement len=%d err=%v body=%s", set.Len(), err, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIDTokenIssuerScopeProjectionAndVerification(t *testing.T) {
|
||||
now := time.Date(2026, 7, 20, 10, 0, 0, 0, time.UTC)
|
||||
ring := telegramLoginTestSigningKeys(t, &now)
|
||||
issuer, err := NewIDTokenIssuer(ring, IDTokenIssuerConfig{
|
||||
Issuer: "https://oauth.telesrv.test", Now: func() time.Time { return now },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
profileRequest := domain.TelegramLoginRequest{
|
||||
ClientID: "9001", SigningAlgorithm: domain.TelegramLoginSigningRS256,
|
||||
Scopes: []domain.TelegramLoginScope{
|
||||
domain.TelegramLoginScopeOpenID, domain.TelegramLoginScopeProfile, domain.TelegramLoginScopePhone,
|
||||
},
|
||||
Nonce: "request-nonce", Status: domain.TelegramLoginRequestApproved, AuthorizedUserID: 42,
|
||||
ProfileName: "Alice Example", GivenName: "Alice", FamilyName: "Example",
|
||||
PreferredUsername: "alice", Picture: "https://oauth.telesrv.test/userpic/42",
|
||||
PhoneNumber: "15551234567", PhoneShared: true, ApprovedAt: now.Add(-time.Minute),
|
||||
}
|
||||
signed, err := issuer.Issue(profileRequest)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
jwksBody, _, err := ring.JWKS()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
set, err := jwk.Parse(jwksBody)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
token, err := jwt.Parse([]byte(signed), jwt.WithKeySet(set), jwt.WithValidate(false))
|
||||
if err != nil {
|
||||
t.Fatalf("verify signed token: %v", err)
|
||||
}
|
||||
issuerValue, _ := token.Issuer()
|
||||
subject, _ := token.Subject()
|
||||
audience, _ := token.Audience()
|
||||
if issuerValue != "https://oauth.telesrv.test" || subject != "42" || len(audience) != 1 || audience[0] != "9001" {
|
||||
t.Fatalf("standard claims iss=%q sub=%q aud=%#v", issuerValue, subject, audience)
|
||||
}
|
||||
var id float64
|
||||
var name, phone, nonce string
|
||||
var verified bool
|
||||
if err := token.Get("id", &id); err != nil || id != 42 {
|
||||
t.Fatalf("id claim=%v err=%v", id, err)
|
||||
}
|
||||
if err := token.Get("name", &name); err != nil || name != "Alice Example" {
|
||||
t.Fatalf("name claim=%q err=%v", name, err)
|
||||
}
|
||||
if err := token.Get("phone_number", &phone); err != nil || phone != "15551234567" {
|
||||
t.Fatalf("phone claim=%q err=%v", phone, err)
|
||||
}
|
||||
if err := token.Get("phone_number_verified", &verified); err != nil || !verified {
|
||||
t.Fatalf("phone verified=%v err=%v", verified, err)
|
||||
}
|
||||
if err := token.Get("nonce", &nonce); err != nil || nonce != "request-nonce" {
|
||||
t.Fatalf("nonce=%q err=%v", nonce, err)
|
||||
}
|
||||
|
||||
openidOnly := profileRequest
|
||||
openidOnly.SigningAlgorithm = domain.TelegramLoginSigningEdDSA
|
||||
openidOnly.Scopes = []domain.TelegramLoginScope{domain.TelegramLoginScopeOpenID}
|
||||
openidOnly.ProfileName = ""
|
||||
openidOnly.GivenName = ""
|
||||
openidOnly.FamilyName = ""
|
||||
openidOnly.PreferredUsername = ""
|
||||
openidOnly.Picture = ""
|
||||
openidOnly.PhoneNumber = ""
|
||||
openidOnly.PhoneShared = false
|
||||
signed, err = issuer.Issue(openidOnly)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
token, err = jwt.Parse([]byte(signed), jwt.WithKeySet(set), jwt.WithValidate(false))
|
||||
if err != nil {
|
||||
t.Fatalf("verify EdDSA token: %v", err)
|
||||
}
|
||||
if token.Has("id") || token.Has("name") || token.Has("phone_number") {
|
||||
t.Fatalf("openid-only token leaked optional claims: %#v", token.Keys())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSigningKeyRingRejectsWrongCurveAndDuplicateActiveKey(t *testing.T) {
|
||||
p384, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := NewSigningKeyRing([]SigningKeyMaterial{{
|
||||
Algorithm: domain.TelegramLoginSigningES256, PrivateKey: p384, Active: true,
|
||||
}}, nil); err == nil {
|
||||
t.Fatal("P-384 key unexpectedly accepted for ES256")
|
||||
}
|
||||
key1, _ := rsa.GenerateKey(rand.Reader, 2048)
|
||||
key2, _ := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if _, err := NewSigningKeyRing([]SigningKeyMaterial{
|
||||
{Algorithm: domain.TelegramLoginSigningRS256, PrivateKey: key1, Active: true},
|
||||
{Algorithm: domain.TelegramLoginSigningRS256, PrivateKey: key2, Active: true},
|
||||
}, nil); err == nil {
|
||||
t.Fatal("two active RS256 keys unexpectedly accepted")
|
||||
}
|
||||
}
|
||||
186
internal/app/telegramlogin/keyfiles.go
Normal file
186
internal/app/telegramlogin/keyfiles.go
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
package telegramlogin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/lestrrat-go/jwx/v3/jwk"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
const (
|
||||
maxTelegramLoginManifestBytes = 1 << 20
|
||||
maxTelegramLoginKeyBytes = 256 << 10
|
||||
)
|
||||
|
||||
type signingKeyManifest struct {
|
||||
Version int `json:"version"`
|
||||
Keys []signingKeyManifestEntry `json:"keys"`
|
||||
}
|
||||
|
||||
type signingKeyManifestEntry struct {
|
||||
Algorithm domain.TelegramLoginSigningAlgorithm `json:"algorithm"`
|
||||
KeyID string `json:"kid,omitempty"`
|
||||
PrivateKeyFile string `json:"private_key_file"`
|
||||
Active bool `json:"active"`
|
||||
PublishUntil string `json:"publish_until,omitempty"`
|
||||
}
|
||||
|
||||
// LoadSigningKeyRing reads a versioned manifest and private PEM/JWK files.
|
||||
// Relative key paths are resolved against the manifest directory. The caller
|
||||
// should atomically replace files and rebuild/swap the ring when rotating.
|
||||
func LoadSigningKeyRing(path string, now func() time.Time) (*SigningKeyRing, error) {
|
||||
var manifest signingKeyManifest
|
||||
if err := readStrictJSONFile(path, maxTelegramLoginManifestBytes, &manifest); err != nil {
|
||||
return nil, fmt.Errorf("load telegram login signing manifest: %w", err)
|
||||
}
|
||||
if manifest.Version != 1 || len(manifest.Keys) == 0 || len(manifest.Keys) > 32 {
|
||||
return nil, errors.New("telegram login signing manifest has invalid version or key count")
|
||||
}
|
||||
baseDir := filepath.Dir(path)
|
||||
materials := make([]SigningKeyMaterial, 0, len(manifest.Keys))
|
||||
for index, entry := range manifest.Keys {
|
||||
keyPath := strings.TrimSpace(entry.PrivateKeyFile)
|
||||
if !entry.Algorithm.Valid() || keyPath == "" {
|
||||
return nil, fmt.Errorf("telegram login signing manifest key %d is invalid", index)
|
||||
}
|
||||
if !filepath.IsAbs(keyPath) {
|
||||
keyPath = filepath.Join(baseDir, keyPath)
|
||||
}
|
||||
data, err := readBoundedFile(keyPath, maxTelegramLoginKeyBytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read telegram login signing key %d: %w", index, err)
|
||||
}
|
||||
var parsed jwk.Key
|
||||
if len(bytes.TrimSpace(data)) > 0 && bytes.TrimSpace(data)[0] == '{' {
|
||||
parsed, err = jwk.ParseKey(data)
|
||||
} else {
|
||||
parsed, err = jwk.ParseKey(data, jwk.WithPEM(true))
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse telegram login signing key %d: %w", index, err)
|
||||
}
|
||||
var raw any
|
||||
if err := jwk.Export(parsed, &raw); err != nil {
|
||||
return nil, fmt.Errorf("export telegram login signing key %d: %w", index, err)
|
||||
}
|
||||
var publishUntil time.Time
|
||||
if entry.PublishUntil != "" {
|
||||
publishUntil, err = time.Parse(time.RFC3339, entry.PublishUntil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse telegram login signing key %d publish_until: %w", index, err)
|
||||
}
|
||||
}
|
||||
if entry.Active && !publishUntil.IsZero() {
|
||||
return nil, fmt.Errorf("active telegram login signing key %d must not set publish_until", index)
|
||||
}
|
||||
if !entry.Active && publishUntil.IsZero() {
|
||||
return nil, fmt.Errorf("retiring telegram login signing key %d requires publish_until", index)
|
||||
}
|
||||
materials = append(materials, SigningKeyMaterial{
|
||||
Algorithm: entry.Algorithm, KeyID: entry.KeyID, PrivateKey: raw,
|
||||
Active: entry.Active, PublishUntil: publishUntil,
|
||||
})
|
||||
}
|
||||
return NewSigningKeyRing(materials, now)
|
||||
}
|
||||
|
||||
type codeKeyManifest struct {
|
||||
Version int `json:"version"`
|
||||
Active string `json:"active"`
|
||||
Keys map[string]string `json:"keys"`
|
||||
}
|
||||
|
||||
func LoadCodeSealer(path string) (*CodeSealer, error) {
|
||||
var manifest codeKeyManifest
|
||||
if err := readStrictJSONFile(path, maxTelegramLoginManifestBytes, &manifest); err != nil {
|
||||
return nil, fmt.Errorf("load telegram login code-key manifest: %w", err)
|
||||
}
|
||||
if manifest.Version != 1 || manifest.Active == "" || len(manifest.Keys) == 0 || len(manifest.Keys) > 16 {
|
||||
return nil, errors.New("telegram login code-key manifest has invalid version or key count")
|
||||
}
|
||||
keys := make(map[string][]byte, len(manifest.Keys))
|
||||
for keyID, encoded := range manifest.Keys {
|
||||
if strings.TrimSpace(keyID) == "" || keyID != strings.TrimSpace(keyID) || len(keyID) > 128 {
|
||||
return nil, errors.New("telegram login code-key manifest has invalid key id")
|
||||
}
|
||||
raw, err := decodeBase64Key(encoded)
|
||||
if err != nil || len(raw) != 32 {
|
||||
return nil, fmt.Errorf("telegram login code-key %q must be 32 base64-encoded bytes", keyID)
|
||||
}
|
||||
keys[keyID] = raw
|
||||
}
|
||||
return NewCodeSealer(manifest.Active, keys)
|
||||
}
|
||||
|
||||
func LoadClientSecretPepper(path string) ([]byte, error) {
|
||||
data, err := readBoundedFile(path, 4096)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read telegram login client-secret pepper: %w", err)
|
||||
}
|
||||
raw, err := decodeBase64Key(strings.TrimSpace(string(data)))
|
||||
if err != nil || len(raw) != 32 {
|
||||
return nil, errors.New("telegram login client-secret pepper must be 32 base64-encoded bytes")
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
func decodeBase64Key(value string) ([]byte, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
for _, encoding := range []*base64.Encoding{
|
||||
base64.RawURLEncoding, base64.URLEncoding, base64.RawStdEncoding, base64.StdEncoding,
|
||||
} {
|
||||
if raw, err := encoding.DecodeString(value); err == nil {
|
||||
return raw, nil
|
||||
}
|
||||
}
|
||||
return nil, errors.New("invalid base64")
|
||||
}
|
||||
|
||||
func readStrictJSONFile(path string, maxBytes int64, target any) error {
|
||||
data, err := readBoundedFile(path, maxBytes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||
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("multiple JSON values")
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readBoundedFile(path string, maxBytes int64) ([]byte, error) {
|
||||
if strings.TrimSpace(path) == "" || maxBytes <= 0 {
|
||||
return nil, errors.New("invalid file path or size bound")
|
||||
}
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !info.Mode().IsRegular() || info.Size() > maxBytes {
|
||||
return nil, errors.New("file is not regular or exceeds size bound")
|
||||
}
|
||||
return io.ReadAll(io.LimitReader(file, maxBytes+1))
|
||||
}
|
||||
86
internal/app/telegramlogin/keyfiles_test.go
Normal file
86
internal/app/telegramlogin/keyfiles_test.go
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
package telegramlogin
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLoadSigningKeyRingAndSymmetricKeyFiles(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
rsaKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pemBytes := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(rsaKey)})
|
||||
if err := os.WriteFile(filepath.Join(dir, "rsa.pem"), pemBytes, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
manifest := map[string]any{
|
||||
"version": 1,
|
||||
"keys": []map[string]any{{
|
||||
"algorithm": "RS256", "kid": "rsa-test", "private_key_file": "rsa.pem", "active": true,
|
||||
}},
|
||||
}
|
||||
manifestBytes, _ := json.Marshal(manifest)
|
||||
manifestPath := filepath.Join(dir, "signing.json")
|
||||
if err := os.WriteFile(manifestPath, manifestBytes, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ring, err := LoadSigningKeyRing(manifestPath, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := ring.SupportedAlgorithms(); len(got) != 1 || got[0] != "RS256" {
|
||||
t.Fatalf("algorithms=%#v", got)
|
||||
}
|
||||
|
||||
codeKey := make([]byte, 32)
|
||||
pepper := make([]byte, 32)
|
||||
_, _ = rand.Read(codeKey)
|
||||
_, _ = rand.Read(pepper)
|
||||
codeManifest, _ := json.Marshal(map[string]any{
|
||||
"version": 1, "active": "2026-07", "keys": map[string]string{"2026-07": base64.RawURLEncoding.EncodeToString(codeKey)},
|
||||
})
|
||||
codePath := filepath.Join(dir, "code-keys.json")
|
||||
pepperPath := filepath.Join(dir, "pepper")
|
||||
if err := os.WriteFile(codePath, codeManifest, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(pepperPath, []byte(base64.RawURLEncoding.EncodeToString(pepper)), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sealer, err := LoadCodeSealer(codePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sealed, nonce, kid, err := sealer.Seal("code", []byte("aad"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if opened, err := sealer.Open(sealed, nonce, kid, []byte("aad")); err != nil || opened != "code" {
|
||||
t.Fatalf("open=%q err=%v", opened, err)
|
||||
}
|
||||
loadedPepper, err := LoadClientSecretPepper(pepperPath)
|
||||
if err != nil || string(loadedPepper) != string(pepper) {
|
||||
t.Fatalf("pepper len=%d err=%v", len(loadedPepper), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadSigningKeyRingRejectsUnknownManifestFieldAndUnboundedRetiringKey(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "bad.json")
|
||||
if err := os.WriteFile(path, []byte(`{"version":1,"keys":[],"unknown":true}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := LoadSigningKeyRing(path, func() time.Time { return time.Now() }); err == nil {
|
||||
t.Fatal("unknown manifest field unexpectedly accepted")
|
||||
}
|
||||
}
|
||||
80
internal/app/telegramlogin/native.go
Normal file
80
internal/app/telegramlogin/native.go
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
package telegramlogin
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
var nativeApplicationIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{2,254}$`)
|
||||
|
||||
func normalizeNativeApplicationID(raw string) (string, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if !nativeApplicationIDPattern.MatchString(raw) || !strings.Contains(raw, ".") || strings.Contains(raw, "..") {
|
||||
return "", domain.ErrTelegramLoginClientInvalid
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
func normalizeNativeVerificationID(platform domain.TelegramLoginNativePlatform, raw string) (string, error) {
|
||||
raw = strings.ToUpper(strings.TrimSpace(raw))
|
||||
switch platform {
|
||||
case domain.TelegramLoginNativeIOS:
|
||||
if len(raw) != 10 || strings.IndexFunc(raw, func(r rune) bool {
|
||||
return !((r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9'))
|
||||
}) >= 0 {
|
||||
return "", domain.ErrTelegramLoginClientInvalid
|
||||
}
|
||||
case domain.TelegramLoginNativeAndroid:
|
||||
raw = strings.ReplaceAll(raw, ":", "")
|
||||
if len(raw) != 64 || strings.IndexFunc(raw, func(r rune) bool {
|
||||
return !((r >= 'A' && r <= 'F') || (r >= '0' && r <= '9'))
|
||||
}) >= 0 {
|
||||
return "", domain.ErrTelegramLoginClientInvalid
|
||||
}
|
||||
default:
|
||||
return "", domain.ErrTelegramLoginClientInvalid
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
// NormalizeNativeCallbackURI accepts the exact HTTPS universal/app link or a
|
||||
// non-web custom scheme registered for a native application. Query and
|
||||
// fragment components are forbidden because OAuth response fields are
|
||||
// appended by the provider and must not collide with application input.
|
||||
func NormalizeNativeCallbackURI(raw string, allowLoopbackHTTP bool) (string, error) {
|
||||
if raw == "" || len(raw) > maxTelegramLoginURLLength || raw != strings.TrimSpace(raw) || strings.IndexFunc(raw, unicode.IsControl) >= 0 {
|
||||
return "", domain.ErrTelegramLoginURLInvalid
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || !u.IsAbs() || u.Opaque != "" || u.User != nil || u.Host == "" || u.RawQuery != "" || u.Fragment != "" || u.RawPath != "" {
|
||||
return "", domain.ErrTelegramLoginURLInvalid
|
||||
}
|
||||
if strings.EqualFold(u.Scheme, "http") || strings.EqualFold(u.Scheme, "https") {
|
||||
normalized, _, err := NormalizeRedirectURI(raw, allowLoopbackHTTP)
|
||||
return normalized, err
|
||||
}
|
||||
scheme := strings.ToLower(u.Scheme)
|
||||
if !validAppScheme(scheme) || scheme == "tg" || scheme == "javascript" || scheme == "data" || scheme == "file" || u.Port() != "" {
|
||||
return "", domain.ErrTelegramLoginURLInvalid
|
||||
}
|
||||
host := strings.ToLower(strings.TrimSuffix(u.Hostname(), "."))
|
||||
if host == "" || strings.IndexFunc(host, func(r rune) bool {
|
||||
return !((r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '.')
|
||||
}) >= 0 {
|
||||
return "", domain.ErrTelegramLoginURLInvalid
|
||||
}
|
||||
u.Scheme, u.Host = scheme, host
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
func normalizeNativeDisplayName(raw string) (string, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" || len(raw) > 128 || strings.IndexFunc(raw, unicode.IsControl) >= 0 {
|
||||
return "", domain.ErrTelegramLoginClientInvalid
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
1374
internal/app/telegramlogin/service.go
Normal file
1374
internal/app/telegramlogin/service.go
Normal file
File diff suppressed because it is too large
Load diff
404
internal/app/telegramlogin/service_test.go
Normal file
404
internal/app/telegramlogin/service_test.go
Normal file
|
|
@ -0,0 +1,404 @@
|
|||
package telegramlogin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/url"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
func TestServiceClientCreationAndSecretRotationAreSingleWinner(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := time.Unix(1_780_000_000, 0).UTC()
|
||||
service, loginStore := newTelegramLoginTestService(t, &now)
|
||||
|
||||
const contenders = 24
|
||||
start := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
var created atomic.Int32
|
||||
var conflicts atomic.Int32
|
||||
for i := 0; i < contenders; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
_, err := service.CreateClient(ctx, 9010, domain.TelegramLoginSigningRS256)
|
||||
switch {
|
||||
case err == nil:
|
||||
created.Add(1)
|
||||
case errors.Is(err, domain.ErrTelegramLoginRequestConflict):
|
||||
conflicts.Add(1)
|
||||
default:
|
||||
t.Errorf("CreateClient: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
if created.Load() != 1 || conflicts.Load() != contenders-1 {
|
||||
t.Fatalf("create winners=%d conflicts=%d", created.Load(), conflicts.Load())
|
||||
}
|
||||
|
||||
client, found, err := loginStore.GetTelegramLoginClientByBot(ctx, 9010)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("GetTelegramLoginClientByBot: found=%v err=%v", found, err)
|
||||
}
|
||||
start = make(chan struct{})
|
||||
created.Store(0)
|
||||
conflicts.Store(0)
|
||||
for i := 0; i < contenders; i++ {
|
||||
wg.Add(1)
|
||||
go func(seed byte) {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
hash := make([]byte, 32)
|
||||
hash[0] = seed
|
||||
_, err := loginStore.RotateTelegramLoginClientSecret(ctx, client.BotUserID, client.SecretVersion, hash, now.Add(time.Second))
|
||||
switch {
|
||||
case err == nil:
|
||||
created.Add(1)
|
||||
case errors.Is(err, domain.ErrTelegramLoginRequestConflict):
|
||||
conflicts.Add(1)
|
||||
default:
|
||||
t.Errorf("RotateTelegramLoginClientSecret: %v", err)
|
||||
}
|
||||
}(byte(i + 1))
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
if created.Load() != 1 || conflicts.Load() != contenders-1 {
|
||||
t.Fatalf("rotate winners=%d conflicts=%d", created.Load(), conflicts.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func newTelegramLoginTestService(t *testing.T, now *time.Time) (*Service, *memory.TelegramLoginStore) {
|
||||
return newTelegramLoginTestServiceWithAlgorithms(t, now, nil)
|
||||
}
|
||||
|
||||
func newTelegramLoginTestServiceWithAlgorithms(t *testing.T, now *time.Time, algorithms []domain.TelegramLoginSigningAlgorithm) (*Service, *memory.TelegramLoginStore) {
|
||||
t.Helper()
|
||||
key := make([]byte, 32)
|
||||
key[0] = 7
|
||||
sealer, err := NewCodeSealer("test", map[string][]byte{"test": key})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loginStore := memory.NewTelegramLoginStore(nil)
|
||||
pepper := make([]byte, 32)
|
||||
pepper[0] = 9
|
||||
service, err := NewService(loginStore, sealer, Config{
|
||||
Issuer: "https://oauth.telesrv.test", AppScheme: "telesrv",
|
||||
AllowLoopbackHTTP: true, ClientSecretPepper: pepper,
|
||||
SupportedSigningAlgorithms: algorithms,
|
||||
Now: func() time.Time { return *now },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return service, loginStore
|
||||
}
|
||||
|
||||
func TestServiceAcceptsOfficialClientCanonicalOAuthDeepLinks(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := time.Unix(1_780_000_000, 0).UTC()
|
||||
service, _ := newTelegramLoginTestService(t, &now)
|
||||
credentials, err := service.CreateClient(ctx, 9030, domain.TelegramLoginSigningRS256)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const redirectURI = "https://rp.example/callback"
|
||||
if _, err := service.AddAllowedURL(ctx, 9030, domain.TelegramLoginAllowedRedirectURI, redirectURI); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
challenge, err := PKCEChallenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
created, err := service.CreateAuthorization(ctx, CreateAuthorizationParams{
|
||||
ClientID: credentials.Client.ClientID, RedirectURI: redirectURI, ResponseType: "code",
|
||||
Scope: "openid", CodeChallenge: challenge, CodeChallengeMethod: "S256",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parsed, err := url.Parse(created.DeepLink)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
token := parsed.Query().Get("token")
|
||||
valid := []string{
|
||||
created.DeepLink,
|
||||
"tg://oauth?token=" + url.QueryEscape(token),
|
||||
"tg://resolve?domain=oauth&startapp=" + url.QueryEscape(token),
|
||||
"https://t.me/oauth?startapp=" + url.QueryEscape(token),
|
||||
}
|
||||
for _, deepLink := range valid {
|
||||
request, err := service.RequestByDeepLink(ctx, deepLink)
|
||||
if err != nil || request.ID != created.Request.ID {
|
||||
t.Fatalf("RequestByDeepLink(%q) request=%#v err=%v", deepLink, request, err)
|
||||
}
|
||||
}
|
||||
invalid := []string{
|
||||
"telegram://oauth?token=" + url.QueryEscape(token),
|
||||
"tg://oauth/path?token=" + url.QueryEscape(token),
|
||||
"tg://oauth?token=" + url.QueryEscape(token) + "&token=other",
|
||||
"tg://resolve?domain=oauth&domain=other&startapp=" + url.QueryEscape(token),
|
||||
"tg://resolve?domain=oauth&startapp=" + url.QueryEscape(token) + "&startapp=other",
|
||||
"tg://oauth?token=" + url.QueryEscape(token) + "#fragment",
|
||||
}
|
||||
for _, deepLink := range invalid {
|
||||
if _, err := service.RequestByDeepLink(ctx, deepLink); !errors.Is(err, domain.ErrTelegramLoginURLInvalid) {
|
||||
t.Fatalf("RequestByDeepLink(%q) error=%v, want URL invalid", deepLink, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceRejectsSigningAlgorithmsWithoutActiveKeys(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := time.Unix(1_780_000_000, 0).UTC()
|
||||
service, loginStore := newTelegramLoginTestServiceWithAlgorithms(t, &now, []domain.TelegramLoginSigningAlgorithm{
|
||||
domain.TelegramLoginSigningES256,
|
||||
})
|
||||
if _, err := service.CreateClient(ctx, 9020, domain.TelegramLoginSigningRS256); !errors.Is(err, domain.ErrTelegramLoginClientInvalid) {
|
||||
t.Fatalf("CreateClient unsupported algorithm error=%v", err)
|
||||
}
|
||||
credentials, created, err := service.EnsureClient(ctx, 9020)
|
||||
if err != nil || !created || credentials.Client.SigningAlgorithm != domain.TelegramLoginSigningES256 {
|
||||
t.Fatalf("EnsureClient credentials=%#v created=%v err=%v", credentials, created, err)
|
||||
}
|
||||
if _, err := service.SetClientSigningAlgorithm(ctx, 9020, domain.TelegramLoginSigningEdDSA); !errors.Is(err, domain.ErrTelegramLoginClientInvalid) {
|
||||
t.Fatalf("SetClientSigningAlgorithm unsupported error=%v", err)
|
||||
}
|
||||
|
||||
// Simulate configuration drift from a previous deployment. Authorization
|
||||
// must fail before a request is persisted instead of failing after consent.
|
||||
if _, err := loginStore.SetTelegramLoginClientSigningAlgorithm(ctx, 9020, domain.TelegramLoginSigningRS256, now.Add(time.Second)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := service.SetClientEnabled(ctx, 9020, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := service.SetClientEnabled(ctx, 9020, true); !errors.Is(err, domain.ErrTelegramLoginClientInvalid) {
|
||||
t.Fatalf("SetClientEnabled unavailable algorithm error=%v", err)
|
||||
}
|
||||
if _, err := service.AddAllowedURL(ctx, 9020, domain.TelegramLoginAllowedWebOrigin, "https://rp.example"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := service.CreateAuthorization(ctx, CreateAuthorizationParams{
|
||||
ClientID: credentials.Client.ClientID, RedirectURI: "https://rp.example/", ResponseType: "post_message",
|
||||
Scope: "openid profile",
|
||||
}); !errors.Is(err, domain.ErrTelegramLoginClientDisabled) {
|
||||
t.Fatalf("CreateAuthorization unavailable algorithm error=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceAuthorizationCodeFlowAndRevocation(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := time.Unix(1_780_000_000, 0).UTC()
|
||||
service, _ := newTelegramLoginTestService(t, &now)
|
||||
credentials, err := service.CreateClient(ctx, 9001, domain.TelegramLoginSigningRS256)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateClient: %v", err)
|
||||
}
|
||||
const redirectURI = "https://rp.example/callback"
|
||||
if _, err := service.AddAllowedURL(ctx, 9001, domain.TelegramLoginAllowedRedirectURI, redirectURI); err != nil {
|
||||
t.Fatalf("AddAllowedURL redirect: %v", err)
|
||||
}
|
||||
if _, err := service.AddAllowedURL(ctx, 9001, domain.TelegramLoginAllowedWebOrigin, "https://rp.example"); err != nil {
|
||||
t.Fatalf("AddAllowedURL origin: %v", err)
|
||||
}
|
||||
const verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
|
||||
challenge, _ := PKCEChallenge(verifier)
|
||||
created, err := service.CreateAuthorization(ctx, CreateAuthorizationParams{
|
||||
ClientID: credentials.Client.ClientID, RedirectURI: redirectURI,
|
||||
ResponseType: "code", Scope: "openid profile phone telegram:bot_access",
|
||||
State: "opaque-state", Nonce: "nonce", CodeChallenge: challenge, CodeChallengeMethod: "S256",
|
||||
Browser: "Firefox", Platform: "Windows", IP: "192.0.2.10", Region: "Test Region",
|
||||
IncludeMatchCodes: true, MatchCodesFirst: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAuthorization: %v", err)
|
||||
}
|
||||
if created.DeepLink == "" || created.Request.ID == 0 || len(created.Request.MatchCodes) != 5 {
|
||||
t.Fatalf("created authorization = %#v", created)
|
||||
}
|
||||
if _, err := service.CheckMatchCode(ctx, created.DeepLink, created.Request.MatchCodes[0]); err == nil && created.Request.MatchCodes[0] != created.Request.MatchCode {
|
||||
t.Fatal("wrong match code unexpectedly accepted")
|
||||
}
|
||||
if ok, err := service.CheckMatchCode(ctx, created.DeepLink, created.Request.MatchCode); err != nil || !ok {
|
||||
t.Fatalf("CheckMatchCode correct = %v,%v", ok, err)
|
||||
}
|
||||
now = now.Add(time.Second)
|
||||
identity := domain.TelegramLoginIdentitySnapshot{
|
||||
UserID: 42, Name: "Alice Example", GivenName: "Alice", FamilyName: "Example",
|
||||
PreferredUsername: "alice", Picture: "https://oauth.telesrv.test/userpic/42",
|
||||
}
|
||||
approved, web, err := service.Approve(ctx, created.DeepLink, identity, true, false, created.Request.MatchCode)
|
||||
if err != nil {
|
||||
t.Fatalf("Approve: %v", err)
|
||||
}
|
||||
if approved.Status != domain.TelegramLoginRequestApproved || web.PhoneShared || !web.BotAccessGranted {
|
||||
t.Fatalf("approved=%#v web=%#v", approved, web)
|
||||
}
|
||||
if approved.ProfileName != "Alice Example" || approved.PhoneNumber != "" {
|
||||
t.Fatalf("identity snapshot = %#v", approved)
|
||||
}
|
||||
now = now.Add(time.Second)
|
||||
finalized, err := service.FinalizeByBrowserToken(ctx, created.BrowserToken)
|
||||
if err != nil {
|
||||
t.Fatalf("FinalizeByBrowserToken: %v", err)
|
||||
}
|
||||
redirect, err := url.Parse(finalized.RedirectURL)
|
||||
if err != nil || redirect.Query().Get("code") != finalized.Code || redirect.Query().Get("state") != "opaque-state" {
|
||||
t.Fatalf("final redirect = %q,%v", finalized.RedirectURL, err)
|
||||
}
|
||||
if _, err := service.ExchangeAuthorizationCode(ctx, ExchangeAuthorizationCodeParams{
|
||||
Code: finalized.Code, ClientID: credentials.Client.ClientID, ClientSecret: credentials.Secret,
|
||||
RedirectURI: redirectURI, CodeVerifier: verifier + "x",
|
||||
}); !errors.Is(err, domain.ErrTelegramLoginCodeInvalid) {
|
||||
t.Fatalf("exchange wrong verifier error = %v, want code invalid", err)
|
||||
}
|
||||
now = now.Add(time.Second)
|
||||
exchanged, err := service.ExchangeAuthorizationCode(ctx, ExchangeAuthorizationCodeParams{
|
||||
Code: finalized.Code, ClientID: credentials.Client.ClientID, ClientSecret: credentials.Secret,
|
||||
RedirectURI: redirectURI, CodeVerifier: verifier,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ExchangeAuthorizationCode: %v", err)
|
||||
}
|
||||
if exchanged.Request.AuthorizedUserID != 42 || exchanged.WebAuthorization.Hash != web.Hash {
|
||||
t.Fatalf("exchanged = %#v", exchanged)
|
||||
}
|
||||
if _, err := service.ExchangeAuthorizationCode(ctx, ExchangeAuthorizationCodeParams{
|
||||
Code: finalized.Code, ClientID: credentials.Client.ClientID, ClientSecret: credentials.Secret,
|
||||
RedirectURI: redirectURI, CodeVerifier: verifier,
|
||||
}); !errors.Is(err, domain.ErrTelegramLoginCodeConsumed) {
|
||||
t.Fatalf("replay exchange error = %v, want consumed", err)
|
||||
}
|
||||
if err := service.RevokeWebAuthorization(ctx, 42, web.Hash); err != nil {
|
||||
t.Fatalf("RevokeWebAuthorization: %v", err)
|
||||
}
|
||||
if list, err := service.ListWebAuthorizations(ctx, 42); err != nil || len(list) != 0 {
|
||||
t.Fatalf("ListWebAuthorizations after revoke = %#v,%v", list, err)
|
||||
}
|
||||
if err := service.RevokeWebAuthorization(ctx, 42, web.Hash); !errors.Is(err, domain.ErrTelegramLoginWebAuthHashInvalid) {
|
||||
t.Fatalf("second revoke error = %v, want hash invalid", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFinalizationRetryRechecksLiveAuthorization(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := time.Unix(1_780_000_000, 0).UTC()
|
||||
service, _ := newTelegramLoginTestService(t, &now)
|
||||
credentials, err := service.CreateClient(ctx, 9010, domain.TelegramLoginSigningRS256)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const redirectURI = "https://retry.example/callback"
|
||||
const origin = "https://retry.example"
|
||||
if _, err := service.AddAllowedURL(ctx, 9010, domain.TelegramLoginAllowedRedirectURI, redirectURI); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := service.AddAllowedURL(ctx, 9010, domain.TelegramLoginAllowedWebOrigin, origin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
challenge, err := PKCEChallenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
codeRequest, err := service.CreateAuthorization(ctx, CreateAuthorizationParams{
|
||||
ClientID: credentials.Client.ClientID, RedirectURI: redirectURI, ResponseType: "code",
|
||||
Scope: "openid", CodeChallenge: challenge, CodeChallengeMethod: "S256",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, codeWeb, err := service.Approve(ctx, codeRequest.DeepLink, domain.TelegramLoginIdentitySnapshot{UserID: 51}, false, false, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := service.FinalizeByBrowserToken(ctx, codeRequest.BrowserToken); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := service.RevokeWebAuthorization(ctx, 51, codeWeb.Hash); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := service.FinalizeByBrowserToken(ctx, codeRequest.BrowserToken); !errors.Is(err, domain.ErrTelegramLoginRequestConflict) {
|
||||
t.Fatalf("authorization-code retry after revoke error = %v, want conflict", err)
|
||||
}
|
||||
|
||||
miniRequest, err := service.CreateAuthorization(ctx, CreateAuthorizationParams{
|
||||
ClientID: credentials.Client.ClientID, RedirectURI: origin + "/", ResponseType: "post_message", Scope: "openid",
|
||||
Origin: origin, InAppOrigin: origin, Source: domain.TelegramLoginRequestMiniApp,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, miniWeb, err := service.Approve(ctx, miniRequest.DeepLink, domain.TelegramLoginIdentitySnapshot{UserID: 52}, false, false, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := service.FinalizeInAppRedirectByDeepLink(ctx, miniRequest.DeepLink); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := service.RevokeWebAuthorization(ctx, 52, miniWeb.Hash); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := service.FinalizeInAppRedirectByDeepLink(ctx, miniRequest.DeepLink); !errors.Is(err, domain.ErrTelegramLoginRequestConflict) {
|
||||
t.Fatalf("Mini App token retry after revoke error = %v, want conflict", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceSecretRotationClosesExchangeTOCTOU(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := time.Unix(1_780_000_000, 0).UTC()
|
||||
service, _ := newTelegramLoginTestService(t, &now)
|
||||
oldCredentials, err := service.CreateClient(ctx, 9002, domain.TelegramLoginSigningRS256)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const redirectURI = "https://rotate.example/callback"
|
||||
if _, err := service.AddAllowedURL(ctx, 9002, domain.TelegramLoginAllowedRedirectURI, redirectURI); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
|
||||
challenge, _ := PKCEChallenge(verifier)
|
||||
created, err := service.CreateAuthorization(ctx, CreateAuthorizationParams{
|
||||
ClientID: oldCredentials.Client.ClientID, RedirectURI: redirectURI, ResponseType: "code",
|
||||
Scope: "openid", CodeChallenge: challenge, CodeChallengeMethod: "S256",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now = now.Add(time.Second)
|
||||
if _, _, err := service.Approve(ctx, created.DeepLink, domain.TelegramLoginIdentitySnapshot{UserID: 43}, false, false, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now = now.Add(time.Second)
|
||||
finalized, err := service.FinalizeByBrowserToken(ctx, created.BrowserToken)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
newCredentials, err := service.RotateClientSecret(ctx, 9002)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := service.ExchangeAuthorizationCode(ctx, ExchangeAuthorizationCodeParams{
|
||||
Code: finalized.Code, ClientID: oldCredentials.Client.ClientID, ClientSecret: oldCredentials.Secret,
|
||||
RedirectURI: redirectURI, CodeVerifier: verifier,
|
||||
}); !errors.Is(err, domain.ErrTelegramLoginSecretInvalid) {
|
||||
t.Fatalf("old secret exchange error = %v", err)
|
||||
}
|
||||
if _, err := service.ExchangeAuthorizationCode(ctx, ExchangeAuthorizationCodeParams{
|
||||
Code: finalized.Code, ClientID: newCredentials.Client.ClientID, ClientSecret: newCredentials.Secret,
|
||||
RedirectURI: redirectURI, CodeVerifier: verifier,
|
||||
}); err != nil {
|
||||
t.Fatalf("new secret exchange: %v", err)
|
||||
}
|
||||
}
|
||||
141
internal/app/telegramlogin/url.go
Normal file
141
internal/app/telegramlogin/url.go
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
package telegramlogin
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"golang.org/x/net/idna"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
const maxTelegramLoginURLLength = 4096
|
||||
|
||||
func NormalizeRedirectURI(raw string, allowLoopbackHTTP bool) (normalized, domainName string, err error) {
|
||||
u, err := parseWebURL(raw, allowLoopbackHTTP)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if u.Fragment != "" {
|
||||
return "", "", domain.ErrTelegramLoginURLInvalid
|
||||
}
|
||||
query := u.Query()
|
||||
for _, reserved := range []string{"code", "state", "error", "error_description"} {
|
||||
if _, exists := query[reserved]; exists {
|
||||
return "", "", domain.ErrTelegramLoginURLInvalid
|
||||
}
|
||||
}
|
||||
if u.Path == "" {
|
||||
u.Path = "/"
|
||||
}
|
||||
return u.String(), u.Hostname(), nil
|
||||
}
|
||||
|
||||
func NormalizeWebOrigin(raw string, allowLoopbackHTTP bool) (string, error) {
|
||||
u, err := parseWebURL(raw, allowLoopbackHTTP)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if (u.Path != "" && u.Path != "/") || u.RawQuery != "" || u.Fragment != "" || u.RawPath != "" {
|
||||
return "", domain.ErrTelegramLoginURLInvalid
|
||||
}
|
||||
u.Path = ""
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
func parseWebURL(raw string, allowLoopbackHTTP bool) (*url.URL, error) {
|
||||
if raw == "" || len(raw) > maxTelegramLoginURLLength || raw != strings.TrimSpace(raw) || strings.IndexFunc(raw, unicode.IsControl) >= 0 {
|
||||
return nil, domain.ErrTelegramLoginURLInvalid
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || !u.IsAbs() || u.Opaque != "" || u.User != nil || u.Host == "" {
|
||||
return nil, domain.ErrTelegramLoginURLInvalid
|
||||
}
|
||||
u.Scheme = strings.ToLower(u.Scheme)
|
||||
host := strings.TrimSuffix(strings.ToLower(u.Hostname()), ".")
|
||||
if host == "" {
|
||||
return nil, domain.ErrTelegramLoginURLInvalid
|
||||
}
|
||||
if ip := net.ParseIP(host); ip == nil {
|
||||
host, err = idna.Lookup.ToASCII(host)
|
||||
if err != nil || host == "" {
|
||||
return nil, domain.ErrTelegramLoginURLInvalid
|
||||
}
|
||||
}
|
||||
port := u.Port()
|
||||
if port != "" {
|
||||
n, err := strconv.Atoi(port)
|
||||
if err != nil || n < 1 || n > 65535 {
|
||||
return nil, domain.ErrTelegramLoginURLInvalid
|
||||
}
|
||||
}
|
||||
switch u.Scheme {
|
||||
case "https":
|
||||
if port == "443" {
|
||||
port = ""
|
||||
}
|
||||
case "http":
|
||||
if !allowLoopbackHTTP || !isLoopbackHost(host) {
|
||||
return nil, domain.ErrTelegramLoginURLInvalid
|
||||
}
|
||||
if port == "80" {
|
||||
port = ""
|
||||
}
|
||||
default:
|
||||
return nil, domain.ErrTelegramLoginURLInvalid
|
||||
}
|
||||
if port == "" {
|
||||
if ip := net.ParseIP(host); ip != nil && strings.Contains(host, ":") {
|
||||
u.Host = "[" + host + "]"
|
||||
} else {
|
||||
u.Host = host
|
||||
}
|
||||
} else {
|
||||
u.Host = net.JoinHostPort(host, port)
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func isLoopbackHost(host string) bool {
|
||||
if host == "localhost" {
|
||||
return true
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
return ip != nil && ip.IsLoopback()
|
||||
}
|
||||
|
||||
func AppendAuthorizationResult(redirectURI, code, state string) (string, error) {
|
||||
u, err := url.Parse(redirectURI)
|
||||
if err != nil || !u.IsAbs() || code == "" {
|
||||
return "", domain.ErrTelegramLoginURLInvalid
|
||||
}
|
||||
q := u.Query()
|
||||
q.Set("code", code)
|
||||
if state != "" {
|
||||
q.Set("state", state)
|
||||
}
|
||||
u.RawQuery = q.Encode()
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
func AppendAuthorizationError(redirectURI, errorCode, state string) (string, error) {
|
||||
switch errorCode {
|
||||
case "access_denied", "temporarily_unavailable", "server_error", "invalid_request", "invalid_scope", "unsupported_response_type":
|
||||
default:
|
||||
return "", domain.ErrTelegramLoginRequestInvalid
|
||||
}
|
||||
u, err := url.Parse(redirectURI)
|
||||
if err != nil || !u.IsAbs() {
|
||||
return "", domain.ErrTelegramLoginURLInvalid
|
||||
}
|
||||
q := u.Query()
|
||||
q.Set("error", errorCode)
|
||||
if state != "" {
|
||||
q.Set("state", state)
|
||||
}
|
||||
u.RawQuery = q.Encode()
|
||||
return u.String(), nil
|
||||
}
|
||||
115
internal/app/telegramlogin/url_test.go
Normal file
115
internal/app/telegramlogin/url_test.go
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
package telegramlogin
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestNormalizeRedirectURIIsExactAndRejectsOpenRedirectShapes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
allowHTTP bool
|
||||
want string
|
||||
valid bool
|
||||
}{
|
||||
{name: "https canonical", raw: "https://EXAMPLE.com:443/callback?tenant=one", want: "https://example.com/callback?tenant=one", valid: true},
|
||||
{name: "idna", raw: "https://例子.测试/callback", want: "https://xn--fsqu00a.xn--0zwm56d/callback", valid: true},
|
||||
{name: "loopback dev", raw: "http://127.0.0.1:8080/callback", allowHTTP: true, want: "http://127.0.0.1:8080/callback", valid: true},
|
||||
{name: "http production", raw: "http://example.com/callback"},
|
||||
{name: "userinfo", raw: "https://user@example.com/callback"},
|
||||
{name: "fragment", raw: "https://example.com/callback#token"},
|
||||
{name: "reserved code", raw: "https://example.com/callback?code=attacker"},
|
||||
{name: "leading whitespace", raw: " https://example.com/callback"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
got, _, err := NormalizeRedirectURI(test.raw, test.allowHTTP)
|
||||
if test.valid {
|
||||
if err != nil || got != test.want {
|
||||
t.Fatalf("NormalizeRedirectURI() = %q,%v, want %q,nil", got, err, test.want)
|
||||
}
|
||||
} else if !errors.Is(err, domain.ErrTelegramLoginURLInvalid) {
|
||||
t.Fatalf("NormalizeRedirectURI() error = %v, want URL invalid", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendAuthorizationErrorPreservesState(t *testing.T) {
|
||||
got, err := AppendAuthorizationError("https://example.com/callback?tenant=one", "access_denied", "opaque")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
u, _ := url.Parse(got)
|
||||
if u.Query().Get("tenant") != "one" || u.Query().Get("error") != "access_denied" || u.Query().Get("state") != "opaque" {
|
||||
t.Fatalf("error redirect = %q", got)
|
||||
}
|
||||
if _, err := AppendAuthorizationError("https://example.com/callback", "invalid_client", ""); err == nil {
|
||||
t.Fatal("unsafe authorization error unexpectedly accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeWebOriginRejectsPathAndQuery(t *testing.T) {
|
||||
if got, err := NormalizeWebOrigin("https://Example.com/", false); err != nil || got != "https://example.com" {
|
||||
t.Fatalf("NormalizeWebOrigin = %q,%v", got, err)
|
||||
}
|
||||
for _, raw := range []string{"https://example.com/path", "https://example.com/?x=1", "https://example.com/#x"} {
|
||||
if _, err := NormalizeWebOrigin(raw, false); !errors.Is(err, domain.ErrTelegramLoginURLInvalid) {
|
||||
t.Fatalf("NormalizeWebOrigin(%q) error = %v, want URL invalid", raw, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeLoopbackIPv6PreservesURLBrackets(t *testing.T) {
|
||||
origin, err := NormalizeWebOrigin("http://[0:0:0:0:0:0:0:1]:80/", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if origin != "http://[0:0:0:0:0:0:0:1]" {
|
||||
t.Fatalf("origin=%q", origin)
|
||||
}
|
||||
redirect, domainName, err := NormalizeRedirectURI("http://[::1]/callback", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if redirect != "http://[::1]/callback" || domainName != "::1" {
|
||||
t.Fatalf("redirect=%q domain=%q", redirect, domainName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPKCERFC7636Vector(t *testing.T) {
|
||||
const verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
|
||||
const want = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
|
||||
got, err := PKCEChallenge(verifier)
|
||||
if err != nil || got != want {
|
||||
t.Fatalf("PKCEChallenge = %q,%v, want %q,nil", got, err, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodeSealerUsesAADAndRetiringKeys(t *testing.T) {
|
||||
oldKey := make([]byte, 32)
|
||||
newKey := make([]byte, 32)
|
||||
oldKey[0], newKey[0] = 1, 2
|
||||
old, err := NewCodeSealer("old", map[string][]byte{"old": oldKey})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sealed, nonce, keyID, err := old.Seal("authorization-code", []byte("request-1"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rotated, err := NewCodeSealer("new", map[string][]byte{"old": oldKey, "new": newKey})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, err := rotated.Open(sealed, nonce, keyID, []byte("request-1")); err != nil || got != "authorization-code" {
|
||||
t.Fatalf("Open after rotation = %q,%v", got, err)
|
||||
}
|
||||
if _, err := rotated.Open(sealed, nonce, keyID, []byte("request-2")); !errors.Is(err, domain.ErrTelegramLoginCodeInvalid) {
|
||||
t.Fatalf("Open with wrong AAD error = %v", err)
|
||||
}
|
||||
}
|
||||
|
|
@ -347,6 +347,9 @@ func markupButtonFromAPI(button apiInlineKeyboardButton) (domain.MarkupButton, e
|
|||
if button.CopyTextSet {
|
||||
constructors++
|
||||
}
|
||||
if button.LoginURLSet {
|
||||
constructors++
|
||||
}
|
||||
if constructors != 1 {
|
||||
return domain.MarkupButton{}, errors.New("BUTTON_INVALID")
|
||||
}
|
||||
|
|
@ -357,6 +360,13 @@ func markupButtonFromAPI(button apiInlineKeyboardButton) (domain.MarkupButton, e
|
|||
if button.URLSet {
|
||||
return domain.MarkupButton{Type: domain.MarkupButtonURL, Text: button.Text, URL: button.URL, Style: style, IconCustomEmojiID: icon}, nil
|
||||
}
|
||||
if button.LoginURLSet {
|
||||
return domain.MarkupButton{
|
||||
Type: domain.MarkupButtonLoginURL, Text: button.Text, URL: button.LoginURL,
|
||||
ForwardText: button.LoginForwardText, LoginBotUsername: button.LoginBotUsername,
|
||||
RequestWriteAccess: button.LoginRequestWriteAccess, Style: style, IconCustomEmojiID: icon,
|
||||
}, nil
|
||||
}
|
||||
if button.CallbackDataSet {
|
||||
if button.CallbackData == "" || len([]byte(button.CallbackData)) > domain.MaxCallbackDataLen {
|
||||
return domain.MarkupButton{}, errors.New("BUTTON_DATA_INVALID")
|
||||
|
|
@ -706,6 +716,11 @@ type apiInlineKeyboardButton struct {
|
|||
SwitchInlinePeerTypes []string
|
||||
CopyText string
|
||||
CopyTextSet bool
|
||||
LoginURL string
|
||||
LoginForwardText string
|
||||
LoginBotUsername string
|
||||
LoginRequestWriteAccess bool
|
||||
LoginURLSet bool
|
||||
}
|
||||
|
||||
func (b *apiInlineKeyboardButton) UnmarshalJSON(data []byte) error {
|
||||
|
|
@ -739,6 +754,20 @@ func (b *apiInlineKeyboardButton) UnmarshalJSON(data []byte) error {
|
|||
}
|
||||
b.WebAppURL = app.URL
|
||||
}
|
||||
if raw, ok := fields["login_url"]; ok {
|
||||
b.LoginURLSet = true
|
||||
var login struct {
|
||||
URL string `json:"url"`
|
||||
ForwardText string `json:"forward_text"`
|
||||
BotUsername string `json:"bot_username"`
|
||||
RequestWriteAccess bool `json:"request_write_access"`
|
||||
}
|
||||
if json.Unmarshal(raw, &login) != nil {
|
||||
return errors.New("invalid login url")
|
||||
}
|
||||
b.LoginURL, b.LoginForwardText = login.URL, login.ForwardText
|
||||
b.LoginBotUsername, b.LoginRequestWriteAccess = login.BotUsername, login.RequestWriteAccess
|
||||
}
|
||||
switchActions := 0
|
||||
if raw, ok := fields["switch_inline_query"]; ok {
|
||||
switchActions++
|
||||
|
|
@ -807,7 +836,7 @@ func (b *apiInlineKeyboardButton) UnmarshalJSON(data []byte) error {
|
|||
}
|
||||
for key := range fields {
|
||||
switch key {
|
||||
case "text", "url", "callback_data", "web_app", "switch_inline_query", "switch_inline_query_current_chat", "switch_inline_query_chosen_chat", "copy_text", "style", "icon_custom_emoji_id":
|
||||
case "text", "url", "callback_data", "web_app", "login_url", "switch_inline_query", "switch_inline_query_current_chat", "switch_inline_query_chosen_chat", "copy_text", "style", "icon_custom_emoji_id":
|
||||
default:
|
||||
b.Unsupported = true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -509,6 +509,18 @@ func apiReplyMarkup(markup *domain.MessageReplyMarkup) map[string]any {
|
|||
switch button.Type {
|
||||
case domain.MarkupButtonURL:
|
||||
item["url"] = button.URL
|
||||
case domain.MarkupButtonLoginURL:
|
||||
login := map[string]any{"url": button.URL}
|
||||
if button.ForwardText != "" {
|
||||
login["forward_text"] = button.ForwardText
|
||||
}
|
||||
if button.LoginBotUsername != "" {
|
||||
login["bot_username"] = button.LoginBotUsername
|
||||
}
|
||||
if button.RequestWriteAccess {
|
||||
login["request_write_access"] = true
|
||||
}
|
||||
item["login_url"] = login
|
||||
case domain.MarkupButtonCallback:
|
||||
item["callback_data"] = string(button.Data)
|
||||
case domain.MarkupButtonWebView:
|
||||
|
|
|
|||
|
|
@ -886,6 +886,19 @@ func TestReplyMarkupFromAPIReplyKeyboardVariants(t *testing.T) {
|
|||
if err != nil || webApp == nil || webApp.Inline[0][0].Type != domain.MarkupButtonWebView {
|
||||
t.Fatalf("web_app inline button = %#v err=%v", webApp, err)
|
||||
}
|
||||
login, err := replyMarkupFromAPI(json.RawMessage(`{"inline_keyboard":[[{"text":"Log in","login_url":{"url":"https://example.com/login","forward_text":"Open","bot_username":"auth_bot","request_write_access":true}}]]}`))
|
||||
if err != nil || login == nil {
|
||||
t.Fatalf("login_url inline button = %#v err=%v", login, err)
|
||||
}
|
||||
loginButton := login.Inline[0][0]
|
||||
if loginButton.Type != domain.MarkupButtonLoginURL || loginButton.URL != "https://example.com/login" || loginButton.ForwardText != "Open" ||
|
||||
loginButton.LoginBotUsername != "auth_bot" || !loginButton.RequestWriteAccess {
|
||||
t.Fatalf("login_url button = %#v", loginButton)
|
||||
}
|
||||
projectedLogin := apiReplyMarkup(login)["inline_keyboard"].([][]map[string]any)[0][0]["login_url"].(map[string]any)
|
||||
if projectedLogin["url"] != "https://example.com/login" || projectedLogin["bot_username"] != "auth_bot" || projectedLogin["request_write_access"] != true {
|
||||
t.Fatalf("projected login_url = %#v", projectedLogin)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplyMarkupFromAPIPreservesSemanticButtonStyles(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ package config
|
|||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
|
|
@ -88,6 +90,22 @@ type Config struct {
|
|||
// PublicLinkWebAddr 是公开链接落地页监听地址;为空关闭。
|
||||
// 生产应只监听 loopback,并由 nginx 将 /<username>、/addstickers/、/addemoji/ 与 /addlist/ 反代到该地址。
|
||||
PublicLinkWebAddr string
|
||||
// TelegramLoginEnabled mounts the self-hosted Telegram Login/OIDC provider
|
||||
// on PublicLinkWebAddr. Secrets are file-backed so they are not exposed in
|
||||
// process listings or accidentally copied into tracked .env templates.
|
||||
TelegramLoginEnabled bool
|
||||
TelegramLoginIssuer string
|
||||
TelegramLoginAllowLoopbackHTTP bool
|
||||
TelegramLoginSigningKeysFile string
|
||||
TelegramLoginCodeKeysFile string
|
||||
TelegramLoginSecretPepperFile string
|
||||
TelegramLoginRequestTTL time.Duration
|
||||
TelegramLoginCodeTTL time.Duration
|
||||
TelegramLoginIDTokenTTL time.Duration
|
||||
TelegramLoginTrustedProxyCIDRs []string
|
||||
TelegramLoginRetention time.Duration
|
||||
TelegramLoginSweepInterval time.Duration
|
||||
TelegramLoginSweepBatch int
|
||||
// Admin UI 独立进程配置项保留在统一配置中,cmd/telesrv-admin 也按同名 env 读取。
|
||||
AdminUIAddr string
|
||||
AdminUIPassword string
|
||||
|
|
@ -471,6 +489,19 @@ func Load() (Config, error) {
|
|||
PublicWebBaseURL: publicWebBaseURL,
|
||||
PublicAppName: publicAppName,
|
||||
PublicLinkWebAddr: envAllowEmptyOr("TELESRV_PUBLIC_LINK_WEB_ADDR", ""),
|
||||
TelegramLoginEnabled: envBoolOr("TELESRV_TELEGRAM_LOGIN_ENABLE", false),
|
||||
TelegramLoginIssuer: strings.TrimSuffix(envOr("TELESRV_TELEGRAM_LOGIN_ISSUER", publicBaseURL), "/"),
|
||||
TelegramLoginAllowLoopbackHTTP: envBoolOr("TELESRV_TELEGRAM_LOGIN_ALLOW_LOOPBACK_HTTP", false),
|
||||
TelegramLoginSigningKeysFile: envOr("TELESRV_TELEGRAM_LOGIN_SIGNING_KEYS_FILE", "data/telegram-login/signing-keys.json"),
|
||||
TelegramLoginCodeKeysFile: envOr("TELESRV_TELEGRAM_LOGIN_CODE_KEYS_FILE", "data/telegram-login/code-keys.json"),
|
||||
TelegramLoginSecretPepperFile: envOr("TELESRV_TELEGRAM_LOGIN_SECRET_PEPPER_FILE", "data/telegram-login/client-secret-pepper"),
|
||||
TelegramLoginRequestTTL: envDurationOr("TELESRV_TELEGRAM_LOGIN_REQUEST_TTL", 5*time.Minute),
|
||||
TelegramLoginCodeTTL: envDurationOr("TELESRV_TELEGRAM_LOGIN_CODE_TTL", 2*time.Minute),
|
||||
TelegramLoginIDTokenTTL: envDurationOr("TELESRV_TELEGRAM_LOGIN_ID_TOKEN_TTL", time.Hour),
|
||||
TelegramLoginTrustedProxyCIDRs: envListOr("TELESRV_TELEGRAM_LOGIN_TRUSTED_PROXY_CIDRS", nil),
|
||||
TelegramLoginRetention: envDurationOr("TELESRV_TELEGRAM_LOGIN_RETENTION", 7*24*time.Hour),
|
||||
TelegramLoginSweepInterval: envDurationOr("TELESRV_TELEGRAM_LOGIN_SWEEP_INTERVAL", 5*time.Minute),
|
||||
TelegramLoginSweepBatch: envIntOr("TELESRV_TELEGRAM_LOGIN_SWEEP_BATCH", 500),
|
||||
AdminUIAddr: envOr("TELESRV_ADMIN_UI_ADDR", "127.0.0.1:2600"),
|
||||
AdminUIPassword: envOr("TELESRV_ADMIN_UI_PASSWORD", ""),
|
||||
AdminUIToken: envOr("TELESRV_ADMIN_UI_TOKEN", ""),
|
||||
|
|
@ -629,9 +660,56 @@ func Load() (Config, error) {
|
|||
if err := validateStarGiftConfig(cfg); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if err := validateTelegramLoginConfig(cfg); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func validateTelegramLoginConfig(cfg Config) error {
|
||||
if !cfg.TelegramLoginEnabled {
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(cfg.PublicLinkWebAddr) == "" {
|
||||
return fmt.Errorf("TELESRV_TELEGRAM_LOGIN_ENABLE requires TELESRV_PUBLIC_LINK_WEB_ADDR")
|
||||
}
|
||||
issuer, err := url.Parse(strings.TrimSpace(cfg.TelegramLoginIssuer))
|
||||
if err != nil || issuer.User != nil || issuer.Host == "" || issuer.RawQuery != "" || issuer.Fragment != "" ||
|
||||
(issuer.Path != "" && issuer.Path != "/") {
|
||||
return fmt.Errorf("TELESRV_TELEGRAM_LOGIN_ISSUER must be an absolute origin URL")
|
||||
}
|
||||
switch issuer.Scheme {
|
||||
case "https":
|
||||
case "http":
|
||||
host := issuer.Hostname()
|
||||
ip := net.ParseIP(host)
|
||||
if !cfg.TelegramLoginAllowLoopbackHTTP || (host != "localhost" && (ip == nil || !ip.IsLoopback())) {
|
||||
return fmt.Errorf("TELESRV_TELEGRAM_LOGIN_ISSUER http is allowed only for explicit loopback development")
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("TELESRV_TELEGRAM_LOGIN_ISSUER must use https")
|
||||
}
|
||||
if strings.TrimSpace(cfg.TelegramLoginSigningKeysFile) == "" || strings.TrimSpace(cfg.TelegramLoginCodeKeysFile) == "" || strings.TrimSpace(cfg.TelegramLoginSecretPepperFile) == "" {
|
||||
return fmt.Errorf("TELESRV_TELEGRAM_LOGIN_* key and pepper files are required")
|
||||
}
|
||||
if cfg.TelegramLoginRequestTTL < time.Minute || cfg.TelegramLoginRequestTTL > 15*time.Minute ||
|
||||
cfg.TelegramLoginCodeTTL < 30*time.Second || cfg.TelegramLoginCodeTTL > 10*time.Minute ||
|
||||
cfg.TelegramLoginIDTokenTTL < time.Minute || cfg.TelegramLoginIDTokenTTL > 24*time.Hour {
|
||||
return fmt.Errorf("TELESRV_TELEGRAM_LOGIN TTL values are outside their bounded ranges")
|
||||
}
|
||||
if cfg.TelegramLoginRetention < time.Hour || cfg.TelegramLoginRetention > 90*24*time.Hour ||
|
||||
cfg.TelegramLoginSweepInterval < 10*time.Second || cfg.TelegramLoginSweepInterval > time.Hour ||
|
||||
cfg.TelegramLoginSweepBatch <= 0 || cfg.TelegramLoginSweepBatch > 1000 {
|
||||
return fmt.Errorf("TELESRV_TELEGRAM_LOGIN retention must be 1h..90d, sweep interval 10s..1h, and sweep batch 1..1000")
|
||||
}
|
||||
for _, raw := range cfg.TelegramLoginTrustedProxyCIDRs {
|
||||
if _, err := netip.ParsePrefix(strings.TrimSpace(raw)); err != nil {
|
||||
return fmt.Errorf("TELESRV_TELEGRAM_LOGIN_TRUSTED_PROXY_CIDRS contains invalid CIDR %q: %w", raw, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateStarGiftConfig(cfg Config) error {
|
||||
if cfg.StarGiftSweepInterval <= 0 || cfg.StarGiftSweepBatch <= 0 || cfg.StarGiftSweepBatch > 10000 {
|
||||
return fmt.Errorf("TELESRV_STARGIFT_SWEEP_INTERVAL must be positive and TELESRV_STARGIFT_SWEEP_BATCH must be 1..10000")
|
||||
|
|
|
|||
|
|
@ -35,13 +35,13 @@ func TestLoadDefaultsAdvertiseIPToLoopback(t *testing.T) {
|
|||
|
||||
func TestLoadUsesExplicitAdvertiseIP(t *testing.T) {
|
||||
disableDefaultConfigFile(t)
|
||||
t.Setenv("TELESRV_ADVERTISE_IP", "192.0.2.10")
|
||||
t.Setenv("TELESRV_ADVERTISE_IP", "10.172.61.102")
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.AdvertiseIP != "192.0.2.10" {
|
||||
if cfg.AdvertiseIP != "10.172.61.102" {
|
||||
t.Fatalf("AdvertiseIP = %q, want explicit env", cfg.AdvertiseIP)
|
||||
}
|
||||
}
|
||||
|
|
@ -431,6 +431,83 @@ func TestLoadNormalizesLocalPublicBaseURL(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestLoadTelegramLoginConfig(t *testing.T) {
|
||||
disableDefaultConfigFile(t)
|
||||
t.Setenv("TELESRV_PUBLIC_LINK_WEB_ADDR", "127.0.0.1:2401")
|
||||
t.Setenv("TELESRV_TELEGRAM_LOGIN_ENABLE", "true")
|
||||
t.Setenv("TELESRV_TELEGRAM_LOGIN_ISSUER", "http://127.0.0.1:2401/")
|
||||
t.Setenv("TELESRV_TELEGRAM_LOGIN_ALLOW_LOOPBACK_HTTP", "true")
|
||||
t.Setenv("TELESRV_TELEGRAM_LOGIN_SIGNING_KEYS_FILE", "secrets/signing.json")
|
||||
t.Setenv("TELESRV_TELEGRAM_LOGIN_CODE_KEYS_FILE", "secrets/codes.json")
|
||||
t.Setenv("TELESRV_TELEGRAM_LOGIN_SECRET_PEPPER_FILE", "secrets/pepper")
|
||||
t.Setenv("TELESRV_TELEGRAM_LOGIN_REQUEST_TTL", "7m")
|
||||
t.Setenv("TELESRV_TELEGRAM_LOGIN_CODE_TTL", "90s")
|
||||
t.Setenv("TELESRV_TELEGRAM_LOGIN_ID_TOKEN_TTL", "45m")
|
||||
t.Setenv("TELESRV_TELEGRAM_LOGIN_TRUSTED_PROXY_CIDRS", "127.0.0.0/8,10.0.0.0/8")
|
||||
t.Setenv("TELESRV_TELEGRAM_LOGIN_RETENTION", "48h")
|
||||
t.Setenv("TELESRV_TELEGRAM_LOGIN_SWEEP_INTERVAL", "30s")
|
||||
t.Setenv("TELESRV_TELEGRAM_LOGIN_SWEEP_BATCH", "73")
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if !cfg.TelegramLoginEnabled || cfg.TelegramLoginIssuer != "http://127.0.0.1:2401" || !cfg.TelegramLoginAllowLoopbackHTTP {
|
||||
t.Fatalf("telegram login endpoint config = enabled:%v issuer:%q loopback:%v", cfg.TelegramLoginEnabled, cfg.TelegramLoginIssuer, cfg.TelegramLoginAllowLoopbackHTTP)
|
||||
}
|
||||
if cfg.TelegramLoginSigningKeysFile != "secrets/signing.json" || cfg.TelegramLoginCodeKeysFile != "secrets/codes.json" || cfg.TelegramLoginSecretPepperFile != "secrets/pepper" {
|
||||
t.Fatalf("telegram login secret files = %q / %q / %q", cfg.TelegramLoginSigningKeysFile, cfg.TelegramLoginCodeKeysFile, cfg.TelegramLoginSecretPepperFile)
|
||||
}
|
||||
if cfg.TelegramLoginRequestTTL != 7*time.Minute || cfg.TelegramLoginCodeTTL != 90*time.Second || cfg.TelegramLoginIDTokenTTL != 45*time.Minute ||
|
||||
cfg.TelegramLoginRetention != 48*time.Hour || cfg.TelegramLoginSweepInterval != 30*time.Second || cfg.TelegramLoginSweepBatch != 73 {
|
||||
t.Fatalf("telegram login durations/batch = %v / %v / %v / %v / %v / %d", cfg.TelegramLoginRequestTTL, cfg.TelegramLoginCodeTTL,
|
||||
cfg.TelegramLoginIDTokenTTL, cfg.TelegramLoginRetention, cfg.TelegramLoginSweepInterval, cfg.TelegramLoginSweepBatch)
|
||||
}
|
||||
if len(cfg.TelegramLoginTrustedProxyCIDRs) != 2 || cfg.TelegramLoginTrustedProxyCIDRs[1] != "10.0.0.0/8" {
|
||||
t.Fatalf("trusted proxy CIDRs = %#v", cfg.TelegramLoginTrustedProxyCIDRs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateTelegramLoginConfigRejectsUnsafeOrUnboundedSettings(t *testing.T) {
|
||||
valid := Config{
|
||||
TelegramLoginEnabled: true, PublicLinkWebAddr: "127.0.0.1:2401", TelegramLoginIssuer: "https://login.example.test",
|
||||
TelegramLoginSigningKeysFile: "signing.json", TelegramLoginCodeKeysFile: "codes.json", TelegramLoginSecretPepperFile: "pepper",
|
||||
TelegramLoginRequestTTL: 5 * time.Minute, TelegramLoginCodeTTL: 2 * time.Minute, TelegramLoginIDTokenTTL: time.Hour,
|
||||
TelegramLoginRetention: 7 * 24 * time.Hour, TelegramLoginSweepInterval: 5 * time.Minute, TelegramLoginSweepBatch: 500,
|
||||
}
|
||||
if err := validateTelegramLoginConfig(valid); err != nil {
|
||||
t.Fatalf("valid config: %v", err)
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*Config)
|
||||
}{
|
||||
{name: "missing listener", mutate: func(c *Config) { c.PublicLinkWebAddr = "" }},
|
||||
{name: "issuer path", mutate: func(c *Config) { c.TelegramLoginIssuer = "https://login.example.test/oauth" }},
|
||||
{name: "public http", mutate: func(c *Config) {
|
||||
c.TelegramLoginIssuer = "http://login.example.test"
|
||||
c.TelegramLoginAllowLoopbackHTTP = true
|
||||
}},
|
||||
{name: "loopback http disabled", mutate: func(c *Config) { c.TelegramLoginIssuer = "http://127.0.0.1:2401" }},
|
||||
{name: "missing key file", mutate: func(c *Config) { c.TelegramLoginSigningKeysFile = "" }},
|
||||
{name: "request ttl too long", mutate: func(c *Config) { c.TelegramLoginRequestTTL = 16 * time.Minute }},
|
||||
{name: "code ttl too short", mutate: func(c *Config) { c.TelegramLoginCodeTTL = 29 * time.Second }},
|
||||
{name: "id token ttl too long", mutate: func(c *Config) { c.TelegramLoginIDTokenTTL = 25 * time.Hour }},
|
||||
{name: "retention too short", mutate: func(c *Config) { c.TelegramLoginRetention = 59 * time.Minute }},
|
||||
{name: "sweep unbounded", mutate: func(c *Config) { c.TelegramLoginSweepBatch = 1001 }},
|
||||
{name: "invalid proxy CIDR", mutate: func(c *Config) { c.TelegramLoginTrustedProxyCIDRs = []string{"10.0.0.0/33"} }},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cfg := valid
|
||||
tc.mutate(&cfg)
|
||||
if err := validateTelegramLoginConfig(cfg); err == nil {
|
||||
t.Fatal("unsafe Telegram Login config was accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsInvalidPublicBaseURL(t *testing.T) {
|
||||
disableDefaultConfigFile(t)
|
||||
t.Setenv("TELESRV_PUBLIC_BASE_URL", "https://links.example.test/root?tenant=one")
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package domain
|
|||
|
||||
import (
|
||||
"errors"
|
||||
"net"
|
||||
"net/url"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
|
@ -52,6 +53,11 @@ const (
|
|||
MarkupButtonCallback MarkupButtonType = "callback"
|
||||
// MarkupButtonURL 是 keyboardButtonUrl(点击打开链接)。
|
||||
MarkupButtonURL MarkupButtonType = "url"
|
||||
// MarkupButtonLoginURL is Bot API login_url / inputKeyboardButtonUrlAuth.
|
||||
// The target bot is resolved and the linked origin is verified before the
|
||||
// message is persisted; ButtonID is the stable flattened keyboard index
|
||||
// returned to clients as keyboardButtonUrlAuth.button_id.
|
||||
MarkupButtonLoginURL MarkupButtonType = "login_url"
|
||||
MarkupButtonRequestPhone MarkupButtonType = "request_phone"
|
||||
MarkupButtonRequestLocation MarkupButtonType = "request_location"
|
||||
MarkupButtonRequestPoll MarkupButtonType = "request_poll"
|
||||
|
|
@ -122,6 +128,13 @@ type MarkupButton struct {
|
|||
Data []byte `json:"data,omitempty"`
|
||||
// URL 仅 url 使用。
|
||||
URL string `json:"url,omitempty"`
|
||||
// Login URL-only fields. LoginBotUserID=0 means the sending bot until the
|
||||
// RPC/Bot API edge resolves it. LoginBotUsername is input-only and must be
|
||||
// cleared before persistence.
|
||||
ForwardText string `json:"forward_text,omitempty"`
|
||||
LoginBotUserID int64 `json:"login_bot_user_id,omitempty"`
|
||||
LoginBotUsername string `json:"login_bot_username,omitempty"`
|
||||
RequestWriteAccess bool `json:"request_write_access,omitempty"`
|
||||
// RequiresPassword 仅 callback 使用(keyboardButtonCallback.requires_password,
|
||||
// 2FA SRP 校验 P3 stub)。
|
||||
RequiresPassword bool `json:"requires_password,omitempty"`
|
||||
|
|
@ -343,6 +356,14 @@ func validateMarkupButton(b MarkupButton, replyKeyboard bool) error {
|
|||
if err := validateButtonURL(b.URL); err != nil {
|
||||
return err
|
||||
}
|
||||
case MarkupButtonLoginURL:
|
||||
if err := validateLoginButtonURL(b.URL); err != nil {
|
||||
return err
|
||||
}
|
||||
if b.ButtonID < 0 || b.LoginBotUserID < 0 || utf8.RuneCountInString(b.ForwardText) > MaxReplyKeyboardButtonTextLen ||
|
||||
utf8.RuneCountInString(b.LoginBotUsername) > 64 {
|
||||
return ErrButtonInvalid
|
||||
}
|
||||
case MarkupButtonWebView:
|
||||
if err := validateButtonURL(b.URL); err != nil {
|
||||
return err
|
||||
|
|
@ -374,6 +395,33 @@ func validateButtonURL(raw string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// validateLoginButtonURL performs only the protocol-shape validation shared by
|
||||
// Bot API and MTProto input buttons. The Telegram Login service remains the
|
||||
// authority for the deployment policy: it rejects loopback HTTP unless the
|
||||
// explicit development switch is enabled and the exact origin is registered.
|
||||
func validateLoginButtonURL(raw string) error {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" || len(raw) > MaxBotMenuButtonURLLen {
|
||||
return ErrButtonURLInvalid
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Host == "" || u.User != nil {
|
||||
return ErrButtonURLInvalid
|
||||
}
|
||||
if u.Scheme == "https" {
|
||||
return nil
|
||||
}
|
||||
if u.Scheme != "http" {
|
||||
return ErrButtonURLInvalid
|
||||
}
|
||||
host := strings.ToLower(u.Hostname())
|
||||
ip := net.ParseIP(host)
|
||||
if host != "localhost" && (ip == nil || !ip.IsLoopback()) {
|
||||
return ErrButtonURLInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BotCallbackAnswer 是 bot 对一次 callback query 的应答(setBotCallbackAnswer →
|
||||
// 解挂等待中的 getBotCallbackAnswer)。
|
||||
type BotCallbackAnswer struct {
|
||||
|
|
|
|||
|
|
@ -26,6 +26,10 @@ func TestValidateReplyMarkup(t *testing.T) {
|
|||
{"url http bad", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: MarkupButtonURL, Text: "go", URL: "http://example.com"}}}}, ErrButtonURLInvalid},
|
||||
{"url javascript bad", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: MarkupButtonURL, Text: "go", URL: "javascript:alert(1)"}}}}, ErrButtonURLInvalid},
|
||||
{"url empty bad", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: MarkupButtonURL, Text: "go", URL: ""}}}}, ErrButtonURLInvalid},
|
||||
{"login url loopback http ok", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: MarkupButtonLoginURL, Text: "login", URL: "http://127.0.0.1:8080/login"}}}}, nil},
|
||||
{"login url localhost http ok", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: MarkupButtonLoginURL, Text: "login", URL: "http://localhost:8080/login"}}}}, nil},
|
||||
{"login url public http bad", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: MarkupButtonLoginURL, Text: "login", URL: "http://example.com/login"}}}}, ErrButtonURLInvalid},
|
||||
{"login url credentials bad", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: MarkupButtonLoginURL, Text: "login", URL: "https://user@example.com/login"}}}}, ErrButtonURLInvalid},
|
||||
{"unknown type bad", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: "rainbow", Text: "x"}}}}, ErrButtonTypeInvalid},
|
||||
{"reply keyboard ok", &MessageReplyMarkup{Type: MessageReplyMarkupKeyboard, Keyboard: [][]MarkupButton{{{Type: MarkupButtonText, Text: "Help"}}}, Resize: true, Persistent: true, Placeholder: "Choose"}, nil},
|
||||
{"reply keyboard semantic style ok", &MessageReplyMarkup{Type: MessageReplyMarkupKeyboard, Keyboard: [][]MarkupButton{{{Type: MarkupButtonText, Text: "Delete", Style: MarkupButtonStyleDanger, IconCustomEmojiID: 123}}}}, nil},
|
||||
|
|
|
|||
506
internal/domain/telegram_login.go
Normal file
506
internal/domain/telegram_login.go
Normal file
|
|
@ -0,0 +1,506 @@
|
|||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrTelegramLoginClientInvalid = errors.New("telegram login client invalid")
|
||||
ErrTelegramLoginClientDisabled = errors.New("telegram login client disabled")
|
||||
ErrTelegramLoginURLInvalid = errors.New("telegram login url invalid")
|
||||
ErrTelegramLoginRequestInvalid = errors.New("telegram login request invalid")
|
||||
ErrTelegramLoginRequestExpired = errors.New("telegram login request expired")
|
||||
ErrTelegramLoginRequestConflict = errors.New("telegram login request conflict")
|
||||
ErrTelegramLoginMatchCodeInvalid = errors.New("telegram login match code invalid")
|
||||
ErrTelegramLoginScopeInvalid = errors.New("telegram login scope invalid")
|
||||
ErrTelegramLoginCodeInvalid = errors.New("telegram login code invalid")
|
||||
ErrTelegramLoginCodeConsumed = errors.New("telegram login code consumed")
|
||||
ErrTelegramLoginWebAuthHashInvalid = errors.New("telegram login web authorization hash invalid")
|
||||
ErrTelegramLoginRedirectNotAllowed = errors.New("telegram login redirect not allowed")
|
||||
ErrTelegramLoginOriginNotAllowed = errors.New("telegram login origin not allowed")
|
||||
ErrTelegramLoginSecretInvalid = errors.New("telegram login client secret invalid")
|
||||
ErrTelegramLoginPKCEInvalid = errors.New("telegram login pkce invalid")
|
||||
ErrTelegramLoginAuthorizationsTooMany = errors.New("telegram login authorizations too many")
|
||||
)
|
||||
|
||||
const MaxTelegramLoginWebAuthorizations = 1000
|
||||
|
||||
type TelegramLoginSigningAlgorithm string
|
||||
|
||||
const (
|
||||
TelegramLoginSigningRS256 TelegramLoginSigningAlgorithm = "RS256"
|
||||
TelegramLoginSigningES256 TelegramLoginSigningAlgorithm = "ES256"
|
||||
TelegramLoginSigningEdDSA TelegramLoginSigningAlgorithm = "EdDSA"
|
||||
TelegramLoginSigningES256K TelegramLoginSigningAlgorithm = "ES256K"
|
||||
)
|
||||
|
||||
func (a TelegramLoginSigningAlgorithm) Valid() bool {
|
||||
switch a {
|
||||
case TelegramLoginSigningRS256, TelegramLoginSigningES256, TelegramLoginSigningEdDSA, TelegramLoginSigningES256K:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
type TelegramLoginScope string
|
||||
|
||||
const (
|
||||
TelegramLoginScopeOpenID TelegramLoginScope = "openid"
|
||||
TelegramLoginScopeProfile TelegramLoginScope = "profile"
|
||||
TelegramLoginScopePhone TelegramLoginScope = "phone"
|
||||
TelegramLoginScopeBotAccess TelegramLoginScope = "telegram:bot_access"
|
||||
)
|
||||
|
||||
func (s TelegramLoginScope) Valid() bool {
|
||||
switch s {
|
||||
case TelegramLoginScopeOpenID, TelegramLoginScopeProfile, TelegramLoginScopePhone, TelegramLoginScopeBotAccess:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
type TelegramLoginClient struct {
|
||||
BotUserID int64
|
||||
ClientID string
|
||||
SecretHash []byte
|
||||
SecretVersion int64
|
||||
SigningAlgorithm TelegramLoginSigningAlgorithm
|
||||
Enabled bool
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (c TelegramLoginClient) Clone() TelegramLoginClient {
|
||||
out := c
|
||||
out.SecretHash = append([]byte(nil), c.SecretHash...)
|
||||
return out
|
||||
}
|
||||
|
||||
func (c TelegramLoginClient) Validate() error {
|
||||
if c.BotUserID <= 0 || c.ClientID == "" || len(c.SecretHash) != 32 || c.SecretVersion <= 0 || !c.SigningAlgorithm.Valid() {
|
||||
return ErrTelegramLoginClientInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type TelegramLoginAllowedURLKind string
|
||||
|
||||
const (
|
||||
TelegramLoginAllowedWebOrigin TelegramLoginAllowedURLKind = "web_origin"
|
||||
TelegramLoginAllowedRedirectURI TelegramLoginAllowedURLKind = "redirect_uri"
|
||||
)
|
||||
|
||||
type TelegramLoginAllowedURL struct {
|
||||
ID int64
|
||||
BotUserID int64
|
||||
Kind TelegramLoginAllowedURLKind
|
||||
NormalizedURL string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type TelegramLoginNativePlatform string
|
||||
|
||||
const (
|
||||
TelegramLoginNativeIOS TelegramLoginNativePlatform = "ios"
|
||||
TelegramLoginNativeAndroid TelegramLoginNativePlatform = "android"
|
||||
)
|
||||
|
||||
type TelegramLoginNativeApp struct {
|
||||
ID int64
|
||||
BotUserID int64
|
||||
Platform TelegramLoginNativePlatform
|
||||
ApplicationID string
|
||||
// VerificationID is the 10-character Apple Team ID on iOS and the
|
||||
// normalized 64-hex SHA-256 signing-certificate fingerprint on Android.
|
||||
VerificationID string
|
||||
CallbackURI string
|
||||
VerifiedDisplayName string
|
||||
Enabled bool
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
const MaxTelegramLoginNativeApps = 20
|
||||
|
||||
func (p TelegramLoginNativePlatform) Valid() bool {
|
||||
return p == TelegramLoginNativeIOS || p == TelegramLoginNativeAndroid
|
||||
}
|
||||
|
||||
func (a TelegramLoginNativeApp) Validate() error {
|
||||
if a.BotUserID <= 0 || !a.Platform.Valid() || a.ApplicationID == "" || len(a.ApplicationID) > 255 ||
|
||||
a.VerificationID == "" || a.CallbackURI == "" || len(a.CallbackURI) > 4096 ||
|
||||
a.VerifiedDisplayName == "" || len(a.VerifiedDisplayName) > 128 ||
|
||||
a.CreatedAt.IsZero() || a.UpdatedAt.IsZero() {
|
||||
return ErrTelegramLoginClientInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type TelegramLoginRequestSource string
|
||||
|
||||
const (
|
||||
TelegramLoginRequestWeb TelegramLoginRequestSource = "web"
|
||||
TelegramLoginRequestJavaScript TelegramLoginRequestSource = "javascript"
|
||||
TelegramLoginRequestNative TelegramLoginRequestSource = "native"
|
||||
TelegramLoginRequestMiniApp TelegramLoginRequestSource = "mini_app"
|
||||
TelegramLoginRequestMessageButton TelegramLoginRequestSource = "message_button"
|
||||
)
|
||||
|
||||
type TelegramLoginRequestState string
|
||||
|
||||
const (
|
||||
TelegramLoginRequestPending TelegramLoginRequestState = "pending"
|
||||
TelegramLoginRequestApproved TelegramLoginRequestState = "approved"
|
||||
TelegramLoginRequestDeclined TelegramLoginRequestState = "declined"
|
||||
TelegramLoginRequestExpired TelegramLoginRequestState = "expired"
|
||||
)
|
||||
|
||||
func (s TelegramLoginRequestState) Terminal() bool {
|
||||
return s == TelegramLoginRequestApproved || s == TelegramLoginRequestDeclined || s == TelegramLoginRequestExpired
|
||||
}
|
||||
|
||||
func CanTransitionTelegramLoginRequest(from, to TelegramLoginRequestState) bool {
|
||||
if from != TelegramLoginRequestPending {
|
||||
return false
|
||||
}
|
||||
return to == TelegramLoginRequestApproved || to == TelegramLoginRequestDeclined || to == TelegramLoginRequestExpired
|
||||
}
|
||||
|
||||
type TelegramLoginRequest struct {
|
||||
ID int64
|
||||
RequestTokenHash []byte
|
||||
BrowserTokenHash []byte
|
||||
BotUserID int64
|
||||
ClientID string
|
||||
SigningAlgorithm TelegramLoginSigningAlgorithm
|
||||
Source TelegramLoginRequestSource
|
||||
ResponseType string
|
||||
RedirectURI string
|
||||
Origin string
|
||||
Domain string
|
||||
Scopes []TelegramLoginScope
|
||||
State string
|
||||
Nonce string
|
||||
CodeChallenge string
|
||||
CodeChallengeMethod string
|
||||
Browser string
|
||||
Platform string
|
||||
IP string
|
||||
Region string
|
||||
InAppOrigin string
|
||||
IsApp bool
|
||||
VerifiedAppName string
|
||||
MatchCodes []string
|
||||
MatchCode string
|
||||
MatchCodesFirst bool
|
||||
UserIDHint int64
|
||||
PeerType PeerType
|
||||
PeerID int64
|
||||
MessageID int
|
||||
ButtonID int
|
||||
Status TelegramLoginRequestState
|
||||
AuthorizedUserID int64
|
||||
ProfileName string
|
||||
GivenName string
|
||||
FamilyName string
|
||||
PreferredUsername string
|
||||
Picture string
|
||||
PhoneNumber string
|
||||
WriteAllowed bool
|
||||
PhoneShared bool
|
||||
CreatedAt time.Time
|
||||
ExpiresAt time.Time
|
||||
ApprovedAt time.Time
|
||||
DeclinedAt time.Time
|
||||
}
|
||||
|
||||
func (r TelegramLoginRequest) Clone() TelegramLoginRequest {
|
||||
out := r
|
||||
out.RequestTokenHash = append([]byte(nil), r.RequestTokenHash...)
|
||||
out.BrowserTokenHash = append([]byte(nil), r.BrowserTokenHash...)
|
||||
out.Scopes = append([]TelegramLoginScope(nil), r.Scopes...)
|
||||
out.MatchCodes = append([]string(nil), r.MatchCodes...)
|
||||
return out
|
||||
}
|
||||
|
||||
func (r TelegramLoginRequest) Requests(scope TelegramLoginScope) bool {
|
||||
return slices.Contains(r.Scopes, scope)
|
||||
}
|
||||
|
||||
func (r TelegramLoginRequest) Validate() error {
|
||||
if len(r.RequestTokenHash) != 32 || len(r.BrowserTokenHash) != 32 || r.BotUserID <= 0 || r.ClientID == "" || r.ClientID != strings.TrimSpace(r.ClientID) || r.RedirectURI == "" || r.Domain == "" {
|
||||
return ErrTelegramLoginRequestInvalid
|
||||
}
|
||||
if !r.SigningAlgorithm.Valid() || !r.Source.Valid() || (r.ResponseType != "code" && r.ResponseType != "post_message" && r.ResponseType != "legacy_url") ||
|
||||
r.Status != TelegramLoginRequestPending || r.CreatedAt.IsZero() || !r.ExpiresAt.After(r.CreatedAt) {
|
||||
return ErrTelegramLoginRequestInvalid
|
||||
}
|
||||
switch r.Source {
|
||||
case TelegramLoginRequestWeb:
|
||||
if r.ResponseType != "code" {
|
||||
return ErrTelegramLoginRequestInvalid
|
||||
}
|
||||
case TelegramLoginRequestJavaScript:
|
||||
if r.ResponseType != "post_message" {
|
||||
return ErrTelegramLoginRequestInvalid
|
||||
}
|
||||
case TelegramLoginRequestNative:
|
||||
if r.ResponseType != "code" || !r.IsApp || r.VerifiedAppName == "" || r.Origin != "" {
|
||||
return ErrTelegramLoginRequestInvalid
|
||||
}
|
||||
case TelegramLoginRequestMiniApp:
|
||||
if r.ResponseType != "post_message" {
|
||||
return ErrTelegramLoginRequestInvalid
|
||||
}
|
||||
case TelegramLoginRequestMessageButton:
|
||||
if r.ResponseType != "legacy_url" {
|
||||
return ErrTelegramLoginRequestInvalid
|
||||
}
|
||||
default:
|
||||
return ErrTelegramLoginRequestInvalid
|
||||
}
|
||||
if r.Source != TelegramLoginRequestNative && (r.IsApp || r.VerifiedAppName != "" || r.Origin == "") {
|
||||
return ErrTelegramLoginRequestInvalid
|
||||
}
|
||||
if r.AuthorizedUserID != 0 || r.ProfileName != "" || r.GivenName != "" || r.FamilyName != "" ||
|
||||
r.PreferredUsername != "" || r.Picture != "" || r.PhoneNumber != "" || r.WriteAllowed || r.PhoneShared ||
|
||||
!r.ApprovedAt.IsZero() || !r.DeclinedAt.IsZero() {
|
||||
return ErrTelegramLoginRequestInvalid
|
||||
}
|
||||
if len(r.RedirectURI) > 4096 || len(r.Origin) > 4096 || len(r.Domain) > 255 || len(r.InAppOrigin) > 4096 ||
|
||||
len(r.State) > 2048 || len(r.Nonce) > 1024 || len(r.Browser) == 0 || len(r.Browser) > 255 ||
|
||||
len(r.Platform) == 0 || len(r.Platform) > 255 || len(r.IP) == 0 || len(r.IP) > 128 ||
|
||||
len(r.Region) == 0 || len(r.Region) > 255 || len(r.VerifiedAppName) > 128 || r.UserIDHint < 0 ||
|
||||
r.PeerID < 0 || r.MessageID < 0 || r.ButtonID < 0 || len(r.MatchCodes) > 8 {
|
||||
return ErrTelegramLoginRequestInvalid
|
||||
}
|
||||
if r.ResponseType == "legacy_url" {
|
||||
if r.Source != TelegramLoginRequestMessageButton || r.PeerID <= 0 || r.MessageID <= 0 ||
|
||||
(r.PeerType != PeerTypeUser && r.PeerType != PeerTypeChannel) || r.CodeChallenge != "" || r.CodeChallengeMethod != "" ||
|
||||
len(r.MatchCodes) != 0 || r.MatchCode != "" || r.MatchCodesFirst {
|
||||
return ErrTelegramLoginRequestInvalid
|
||||
}
|
||||
if !slices.Contains(r.Scopes, TelegramLoginScopeOpenID) || !slices.Contains(r.Scopes, TelegramLoginScopeProfile) {
|
||||
return ErrTelegramLoginScopeInvalid
|
||||
}
|
||||
seen := make(map[TelegramLoginScope]struct{}, len(r.Scopes))
|
||||
for _, scope := range r.Scopes {
|
||||
if !scope.Valid() || scope == TelegramLoginScopePhone {
|
||||
return ErrTelegramLoginScopeInvalid
|
||||
}
|
||||
if _, duplicate := seen[scope]; duplicate {
|
||||
return ErrTelegramLoginScopeInvalid
|
||||
}
|
||||
seen[scope] = struct{}{}
|
||||
}
|
||||
} else if r.ResponseType == "code" {
|
||||
if err := ValidateTelegramLoginScopes(r.Scopes, r.SigningAlgorithm); err != nil {
|
||||
return err
|
||||
}
|
||||
if r.CodeChallengeMethod != "S256" || r.CodeChallenge == "" {
|
||||
return ErrTelegramLoginPKCEInvalid
|
||||
}
|
||||
} else {
|
||||
if err := ValidateTelegramLoginScopes(r.Scopes, r.SigningAlgorithm); err != nil {
|
||||
return err
|
||||
}
|
||||
// Telegram's official JavaScript SDK returns an ID token directly and
|
||||
// therefore sends no authorization-code PKCE parameters. Accept a PKCE
|
||||
// pair for generic callers, but never a partial pair.
|
||||
if r.CodeChallenge == "" && r.CodeChallengeMethod == "" {
|
||||
// Official post_message/Mini App shape.
|
||||
} else if r.CodeChallengeMethod != "S256" || r.CodeChallenge == "" {
|
||||
return ErrTelegramLoginPKCEInvalid
|
||||
}
|
||||
}
|
||||
if r.Source == TelegramLoginRequestMiniApp {
|
||||
if r.ResponseType != "post_message" || r.InAppOrigin == "" || r.Origin != r.InAppOrigin {
|
||||
return ErrTelegramLoginRequestInvalid
|
||||
}
|
||||
} else if r.InAppOrigin != "" {
|
||||
return ErrTelegramLoginRequestInvalid
|
||||
}
|
||||
if r.MatchCodesFirst && len(r.MatchCodes) == 0 {
|
||||
return ErrTelegramLoginRequestInvalid
|
||||
}
|
||||
if len(r.MatchCodes) > 0 && (r.MatchCode == "" || !slices.Contains(r.MatchCodes, r.MatchCode)) {
|
||||
return ErrTelegramLoginRequestInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TelegramLoginMessageButtonAuthorization is the domain-only input for the
|
||||
// legacy login_url consent path. BotToken is used transiently to produce the
|
||||
// official HMAC response and is never persisted in the login aggregate.
|
||||
type TelegramLoginMessageButtonAuthorization struct {
|
||||
UserID int64
|
||||
BotUserID int64
|
||||
BotToken string
|
||||
URL string
|
||||
RequestWriteAccess bool
|
||||
WriteAllowed bool
|
||||
Peer Peer
|
||||
MessageID int
|
||||
ButtonID int
|
||||
Browser string
|
||||
Platform string
|
||||
IP string
|
||||
Region string
|
||||
Identity TelegramLoginIdentitySnapshot
|
||||
}
|
||||
|
||||
type TelegramLoginMessageButtonResult struct {
|
||||
URL string
|
||||
Request TelegramLoginRequest
|
||||
WebAuthorization TelegramLoginWebAuthorization
|
||||
}
|
||||
|
||||
func (s TelegramLoginRequestSource) Valid() bool {
|
||||
switch s {
|
||||
case TelegramLoginRequestWeb, TelegramLoginRequestJavaScript, TelegramLoginRequestNative,
|
||||
TelegramLoginRequestMiniApp, TelegramLoginRequestMessageButton:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// TelegramLoginIdentitySnapshot is the immutable identity presented on the
|
||||
// approval screen and later signed into the ID token. It is written together
|
||||
// with the pending->approved transition so a profile/phone mutation between
|
||||
// approval and code exchange cannot change what the relying party receives.
|
||||
type TelegramLoginIdentitySnapshot struct {
|
||||
UserID int64
|
||||
Name string
|
||||
GivenName string
|
||||
FamilyName string
|
||||
PreferredUsername string
|
||||
Picture string
|
||||
PhoneNumber string
|
||||
}
|
||||
|
||||
func (s TelegramLoginIdentitySnapshot) Sanitized(includeProfile, includePhone bool) (TelegramLoginIdentitySnapshot, error) {
|
||||
if s.UserID <= 0 {
|
||||
return TelegramLoginIdentitySnapshot{}, ErrTelegramLoginRequestInvalid
|
||||
}
|
||||
out := TelegramLoginIdentitySnapshot{UserID: s.UserID}
|
||||
if includeProfile {
|
||||
out.Name = strings.TrimSpace(s.Name)
|
||||
out.GivenName = strings.TrimSpace(s.GivenName)
|
||||
out.FamilyName = strings.TrimSpace(s.FamilyName)
|
||||
out.PreferredUsername = strings.TrimSpace(s.PreferredUsername)
|
||||
out.Picture = strings.TrimSpace(s.Picture)
|
||||
if out.Name == "" || out.GivenName == "" {
|
||||
return TelegramLoginIdentitySnapshot{}, ErrTelegramLoginRequestInvalid
|
||||
}
|
||||
}
|
||||
if includePhone {
|
||||
out.PhoneNumber = NormalizePhone(s.PhoneNumber)
|
||||
if !ValidPhone(out.PhoneNumber) {
|
||||
return TelegramLoginIdentitySnapshot{}, ErrPhoneNumberInvalid
|
||||
}
|
||||
}
|
||||
if !boundedUTF8(out.Name, 255) || !boundedUTF8(out.GivenName, 255) || !boundedUTF8(out.FamilyName, 255) ||
|
||||
!boundedUTF8(out.PreferredUsername, 64) || !boundedUTF8(out.Picture, 4096) || len(out.PhoneNumber) > 32 {
|
||||
return TelegramLoginIdentitySnapshot{}, ErrTelegramLoginRequestInvalid
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func boundedUTF8(value string, maxBytes int) bool {
|
||||
return utf8.ValidString(value) && len(value) <= maxBytes
|
||||
}
|
||||
|
||||
func ValidateTelegramLoginScopes(scopes []TelegramLoginScope, alg TelegramLoginSigningAlgorithm) error {
|
||||
if !alg.Valid() || len(scopes) == 0 || !slices.Contains(scopes, TelegramLoginScopeOpenID) {
|
||||
return ErrTelegramLoginScopeInvalid
|
||||
}
|
||||
seen := make(map[TelegramLoginScope]struct{}, len(scopes))
|
||||
for _, scope := range scopes {
|
||||
if !scope.Valid() {
|
||||
return ErrTelegramLoginScopeInvalid
|
||||
}
|
||||
if _, duplicate := seen[scope]; duplicate {
|
||||
return ErrTelegramLoginScopeInvalid
|
||||
}
|
||||
seen[scope] = struct{}{}
|
||||
}
|
||||
if alg == TelegramLoginSigningEdDSA || alg == TelegramLoginSigningES256K {
|
||||
if len(scopes) != 1 || scopes[0] != TelegramLoginScopeOpenID {
|
||||
return ErrTelegramLoginScopeInvalid
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type TelegramLoginApproval struct {
|
||||
RequestID int64
|
||||
Identity TelegramLoginIdentitySnapshot
|
||||
WriteAllowed bool
|
||||
PhoneShared bool
|
||||
MatchCode string
|
||||
ApprovedAt time.Time
|
||||
}
|
||||
|
||||
type TelegramLoginAuthorizationCode struct {
|
||||
ID int64
|
||||
RequestID int64
|
||||
CodeHash []byte
|
||||
SealedCode []byte
|
||||
SealNonce []byte
|
||||
SealKeyID string
|
||||
IssuedAt time.Time
|
||||
ExpiresAt time.Time
|
||||
ConsumedAt time.Time
|
||||
}
|
||||
|
||||
// TelegramLoginCodeExchange carries the values already normalized/hashed by
|
||||
// the application service. The durable store compares them again while the
|
||||
// code/request/client rows are locked, closing redirect, PKCE and secret-
|
||||
// rotation TOCTOU gaps between HTTP validation and one-time consumption.
|
||||
type TelegramLoginCodeExchange struct {
|
||||
CodeHash []byte
|
||||
ClientID string
|
||||
ClientSecretVersion int64
|
||||
RedirectURI string
|
||||
CodeChallenge string
|
||||
Now time.Time
|
||||
}
|
||||
|
||||
func (c TelegramLoginAuthorizationCode) Clone() TelegramLoginAuthorizationCode {
|
||||
out := c
|
||||
out.CodeHash = append([]byte(nil), c.CodeHash...)
|
||||
out.SealedCode = append([]byte(nil), c.SealedCode...)
|
||||
out.SealNonce = append([]byte(nil), c.SealNonce...)
|
||||
return out
|
||||
}
|
||||
|
||||
type TelegramLoginWebAuthorization struct {
|
||||
Hash int64
|
||||
RequestID int64
|
||||
UserID int64
|
||||
BotUserID int64
|
||||
Domain string
|
||||
Browser string
|
||||
Platform string
|
||||
IP string
|
||||
Region string
|
||||
Scopes []TelegramLoginScope
|
||||
PhoneShared bool
|
||||
BotAccessGranted bool
|
||||
CreatedAt time.Time
|
||||
LastActiveAt time.Time
|
||||
RevokedAt time.Time
|
||||
}
|
||||
|
||||
func (a TelegramLoginWebAuthorization) Clone() TelegramLoginWebAuthorization {
|
||||
out := a
|
||||
out.Scopes = append([]TelegramLoginScope(nil), a.Scopes...)
|
||||
return out
|
||||
}
|
||||
112
internal/domain/telegram_login_test.go
Normal file
112
internal/domain/telegram_login_test.go
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
package domain
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestValidateTelegramLoginScopes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
scopes []TelegramLoginScope
|
||||
alg TelegramLoginSigningAlgorithm
|
||||
valid bool
|
||||
}{
|
||||
{name: "rs profile phone", scopes: []TelegramLoginScope{TelegramLoginScopeOpenID, TelegramLoginScopeProfile, TelegramLoginScopePhone}, alg: TelegramLoginSigningRS256, valid: true},
|
||||
{name: "missing openid", scopes: []TelegramLoginScope{TelegramLoginScopeProfile}, alg: TelegramLoginSigningRS256},
|
||||
{name: "duplicate", scopes: []TelegramLoginScope{TelegramLoginScopeOpenID, TelegramLoginScopeOpenID}, alg: TelegramLoginSigningRS256},
|
||||
{name: "unknown", scopes: []TelegramLoginScope{TelegramLoginScopeOpenID, "admin"}, alg: TelegramLoginSigningRS256},
|
||||
{name: "eddsa openid", scopes: []TelegramLoginScope{TelegramLoginScopeOpenID}, alg: TelegramLoginSigningEdDSA, valid: true},
|
||||
{name: "eddsa profile forbidden", scopes: []TelegramLoginScope{TelegramLoginScopeOpenID, TelegramLoginScopeProfile}, alg: TelegramLoginSigningEdDSA},
|
||||
{name: "es256k phone forbidden", scopes: []TelegramLoginScope{TelegramLoginScopeOpenID, TelegramLoginScopePhone}, alg: TelegramLoginSigningES256K},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
err := ValidateTelegramLoginScopes(test.scopes, test.alg)
|
||||
if (err == nil) != test.valid {
|
||||
t.Fatalf("ValidateTelegramLoginScopes() error = %v, valid = %v", err, test.valid)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramLoginRequestTransitions(t *testing.T) {
|
||||
for _, terminal := range []TelegramLoginRequestState{
|
||||
TelegramLoginRequestApproved,
|
||||
TelegramLoginRequestDeclined,
|
||||
TelegramLoginRequestExpired,
|
||||
} {
|
||||
if !CanTransitionTelegramLoginRequest(TelegramLoginRequestPending, terminal) {
|
||||
t.Fatalf("pending -> %s must be valid", terminal)
|
||||
}
|
||||
if CanTransitionTelegramLoginRequest(terminal, TelegramLoginRequestPending) {
|
||||
t.Fatalf("%s -> pending must be forbidden", terminal)
|
||||
}
|
||||
}
|
||||
if CanTransitionTelegramLoginRequest(TelegramLoginRequestApproved, TelegramLoginRequestDeclined) {
|
||||
t.Fatal("approved -> declined must be forbidden")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramLoginRequestSourceShapeMatrix(t *testing.T) {
|
||||
now := time.Unix(1_780_000_000, 0).UTC()
|
||||
base := TelegramLoginRequest{
|
||||
RequestTokenHash: make([]byte, 32), BrowserTokenHash: make([]byte, 32),
|
||||
BotUserID: 9001, ClientID: "9001", SigningAlgorithm: TelegramLoginSigningRS256,
|
||||
Source: TelegramLoginRequestWeb, ResponseType: "code", RedirectURI: "https://rp.example/callback",
|
||||
Origin: "https://rp.example", Domain: "rp.example", Scopes: []TelegramLoginScope{TelegramLoginScopeOpenID},
|
||||
CodeChallenge: strings.Repeat("A", 43), CodeChallengeMethod: "S256",
|
||||
Browser: "Firefox", Platform: "Windows", IP: "192.0.2.1", Region: "Test",
|
||||
Status: TelegramLoginRequestPending, CreatedAt: now, ExpiresAt: now.Add(5 * time.Minute),
|
||||
}
|
||||
if err := base.Validate(); err != nil {
|
||||
t.Fatalf("valid web request: %v", err)
|
||||
}
|
||||
invalid := []struct {
|
||||
name string
|
||||
mutate func(*TelegramLoginRequest)
|
||||
}{
|
||||
{name: "web post message", mutate: func(r *TelegramLoginRequest) {
|
||||
r.ResponseType = "post_message"
|
||||
r.CodeChallenge = ""
|
||||
r.CodeChallengeMethod = ""
|
||||
}},
|
||||
{name: "javascript code", mutate: func(r *TelegramLoginRequest) { r.Source = TelegramLoginRequestJavaScript }},
|
||||
{name: "message button code", mutate: func(r *TelegramLoginRequest) { r.Source = TelegramLoginRequestMessageButton }},
|
||||
{name: "web app flag", mutate: func(r *TelegramLoginRequest) { r.IsApp = true; r.VerifiedAppName = "Forged" }},
|
||||
{name: "web missing origin", mutate: func(r *TelegramLoginRequest) { r.Origin = "" }},
|
||||
}
|
||||
for _, tc := range invalid {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
request := base.Clone()
|
||||
tc.mutate(&request)
|
||||
if err := request.Validate(); err == nil {
|
||||
t.Fatal("forbidden source shape was accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
native := base.Clone()
|
||||
native.Source, native.Origin, native.Domain = TelegramLoginRequestNative, "", "dev.bedolaga.demo"
|
||||
native.IsApp, native.VerifiedAppName = true, "Bedolaga"
|
||||
if err := native.Validate(); err != nil {
|
||||
t.Fatalf("valid native request: %v", err)
|
||||
}
|
||||
native.IsApp = false
|
||||
if err := native.Validate(); err == nil {
|
||||
t.Fatal("native request without verified app state was accepted")
|
||||
}
|
||||
|
||||
mini := base.Clone()
|
||||
mini.Source, mini.ResponseType = TelegramLoginRequestMiniApp, "post_message"
|
||||
mini.CodeChallenge, mini.CodeChallengeMethod = "", ""
|
||||
mini.RedirectURI, mini.InAppOrigin = "https://rp.example/", mini.Origin
|
||||
if err := mini.Validate(); err != nil {
|
||||
t.Fatalf("valid Mini App request: %v", err)
|
||||
}
|
||||
mini.InAppOrigin = "https://other.example"
|
||||
if err := mini.Validate(); err == nil {
|
||||
t.Fatal("Mini App origin mismatch was accepted")
|
||||
}
|
||||
}
|
||||
|
|
@ -380,14 +380,10 @@ func (r *Router) registerAccount(d *tlprofile.Dispatcher) {
|
|||
ID)
|
||||
})
|
||||
registerRPC[*tg.AccountGetWebAuthorizationsRequest](d, tlprofile.SemanticMethodAccountGetWebAuthorizations, func(ctx context.Context, layerRequest *tg.AccountGetWebAuthorizationsRequest) (any, error) {
|
||||
return tdesktop.WebAuthorizations(), nil
|
||||
return r.onAccountGetWebAuthorizations(ctx)
|
||||
})
|
||||
registerRPC[*tg.AccountResetWebAuthorizationRequest](d, tlprofile.SemanticMethodAccountResetWebAuthorization, func(ctx context.Context, layerRequest *tg.AccountResetWebAuthorizationRequest) (any, error) {
|
||||
hash := layerRequest.
|
||||
Hash
|
||||
_ = hash
|
||||
|
||||
return true, nil
|
||||
return r.onAccountResetWebAuthorization(ctx, layerRequest.Hash)
|
||||
})
|
||||
registerRPC[*tg.AccountResetWebAuthorizationsRequest](d, tlprofile.SemanticMethodAccountResetWebAuthorizations, func(ctx context.Context, layerRequest *tg.AccountResetWebAuthorizationsRequest) (
|
||||
|
||||
|
|
@ -395,7 +391,7 @@ func (r *Router) registerAccount(d *tlprofile.Dispatcher) {
|
|||
// (无内置浏览器例外、不强制外部浏览器)。Android 启动时会拉取,缺它会反复 500
|
||||
// NOT_IMPLEMENTED。空结构 Hash=0,客户端按默认(内置浏览器、无例外)渲染。
|
||||
any, error) {
|
||||
return true, nil
|
||||
return r.onAccountResetWebAuthorizations(ctx)
|
||||
})
|
||||
registerRPC[*tg.AccountGetWebBrowserSettingsRequest](d, tlprofile.SemanticMethodAccountGetWebBrowserSettings, func(ctx context.Context, layerRequest *tg.AccountGetWebBrowserSettingsRequest) (any, error) {
|
||||
hash := layerRequest.
|
||||
|
|
|
|||
|
|
@ -834,6 +834,14 @@ func (r *Router) BotAPIEditMessageText(ctx context.Context, botID, chatID int64,
|
|||
return domain.Message{}, errors.New("MESSAGE_TOO_LONG")
|
||||
}
|
||||
peer := domain.Peer{Type: domain.PeerTypeUser, ID: chatID}
|
||||
if setReplyMarkup {
|
||||
if err := domain.ValidateReplyMarkup(replyMarkup); err != nil {
|
||||
return domain.Message{}, replyMarkupErr(err)
|
||||
}
|
||||
if err := r.validateReplyMarkupForPeer(ctx, botID, peer, replyMarkup); err != nil {
|
||||
return domain.Message{}, err
|
||||
}
|
||||
}
|
||||
res, err := r.deps.Messages.EditMessage(ctx, botID, domain.EditMessageRequest{
|
||||
OwnerUserID: botID,
|
||||
Peer: peer,
|
||||
|
|
@ -934,6 +942,11 @@ func (r *Router) BotAPIEditInlineMessageText(ctx context.Context, botID int64, i
|
|||
if err := domain.ValidateReplyMarkup(replyMarkup); err != nil {
|
||||
return false, replyMarkupErr(err)
|
||||
}
|
||||
if setReplyMarkup {
|
||||
if err := r.prepareTelegramLoginMarkup(ctx, botID, replyMarkup); err != nil {
|
||||
return false, replyMarkupErr(err)
|
||||
}
|
||||
}
|
||||
req := &tg.MessagesEditInlineBotMessageRequest{
|
||||
ID: tgInputBotInlineMessageID(inlineMessageID),
|
||||
NoWebpage: disableWebPagePreview,
|
||||
|
|
@ -959,6 +972,11 @@ func (r *Router) BotAPIEditInlineRichMessage(ctx context.Context, botID int64, i
|
|||
if err := domain.ValidateReplyMarkup(replyMarkup); err != nil {
|
||||
return false, replyMarkupErr(err)
|
||||
}
|
||||
if setReplyMarkup {
|
||||
if err := r.prepareTelegramLoginMarkup(ctx, botID, replyMarkup); err != nil {
|
||||
return false, replyMarkupErr(err)
|
||||
}
|
||||
}
|
||||
wire, err := tgInputRichMessageFromBotAPI(input)
|
||||
if err != nil {
|
||||
return false, err
|
||||
|
|
|
|||
|
|
@ -300,6 +300,9 @@ func (r *Router) domainInlineResultsFromTG(ctx context.Context, botID int64, req
|
|||
if err != nil {
|
||||
return domain.BotInlineResults{}, err
|
||||
}
|
||||
if err := r.prepareTelegramLoginMarkup(ctx, botID, item.ReplyMarkup); err != nil {
|
||||
return domain.BotInlineResults{}, replyMarkupErr(err)
|
||||
}
|
||||
if _, ok := seen[item.ID]; ok {
|
||||
return domain.BotInlineResults{}, resultIDDuplicateErr()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package rpc
|
|||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
"github.com/iamxvbaba/td/tgerr"
|
||||
|
|
@ -14,6 +15,9 @@ import (
|
|||
// a chat input field and are not supported in broadcast channels. Inline keyboards remain
|
||||
// valid in both megagroups and broadcasts.
|
||||
func (r *Router) validateReplyMarkupForPeer(ctx context.Context, userID int64, peer domain.Peer, markup *domain.MessageReplyMarkup) error {
|
||||
if err := r.prepareTelegramLoginMarkup(ctx, userID, markup); err != nil {
|
||||
return replyMarkupErr(err)
|
||||
}
|
||||
if markup == nil || !markup.IsReplyKeyboardFamily() || peer.Type != domain.PeerTypeChannel {
|
||||
return nil
|
||||
}
|
||||
|
|
@ -30,6 +34,92 @@ func (r *Router) validateReplyMarkupForPeer(ctx context.Context, userID int64, p
|
|||
return nil
|
||||
}
|
||||
|
||||
// prepareTelegramLoginMarkup resolves every login_url target and validates its
|
||||
// linked web origin before persistence. It mutates only the freshly parsed
|
||||
// request DTO and assigns a deterministic flattened button id, which is later
|
||||
// re-read by messages.requestUrlAuth.
|
||||
func (r *Router) prepareTelegramLoginMarkup(ctx context.Context, senderBotID int64, markup *domain.MessageReplyMarkup) error {
|
||||
if markup == nil || markup.Kind() != domain.MessageReplyMarkupInline {
|
||||
return nil
|
||||
}
|
||||
hasLoginButton := false
|
||||
for rowIndex := range markup.Inline {
|
||||
for buttonIndex := range markup.Inline[rowIndex] {
|
||||
if markup.Inline[rowIndex][buttonIndex].Type == domain.MarkupButtonLoginURL {
|
||||
hasLoginButton = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if hasLoginButton {
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasLoginButton {
|
||||
return nil
|
||||
}
|
||||
if r == nil || r.deps.TelegramLogin == nil || r.deps.Users == nil || senderBotID <= 0 {
|
||||
return domain.ErrButtonTypeInvalid
|
||||
}
|
||||
sender, found, err := r.deps.Users.ByID(ctx, senderBotID, senderBotID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found || !sender.Bot || sender.Deleted {
|
||||
return domain.ErrButtonTypeInvalid
|
||||
}
|
||||
flatID := 0
|
||||
for rowIndex := range markup.Inline {
|
||||
for buttonIndex := range markup.Inline[rowIndex] {
|
||||
button := &markup.Inline[rowIndex][buttonIndex]
|
||||
if button.Type != domain.MarkupButtonLoginURL {
|
||||
flatID++
|
||||
continue
|
||||
}
|
||||
botID := button.LoginBotUserID
|
||||
if button.LoginBotUsername != "" {
|
||||
resolver, ok := r.deps.Users.(UserIdentityService)
|
||||
if !ok {
|
||||
return domain.ErrButtonInvalid
|
||||
}
|
||||
bot, found, err := resolver.ResolveUsername(ctx, senderBotID, strings.TrimPrefix(button.LoginBotUsername, "@"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found || !bot.Bot || bot.Deleted {
|
||||
return domain.ErrButtonInvalid
|
||||
}
|
||||
botID = bot.ID
|
||||
}
|
||||
if botID == 0 {
|
||||
botID = senderBotID
|
||||
}
|
||||
bot, found, err := r.deps.Users.ByID(ctx, senderBotID, botID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found || !bot.Bot || bot.Deleted {
|
||||
return domain.ErrButtonInvalid
|
||||
}
|
||||
normalized, _, err := r.deps.TelegramLogin.ValidateMessageButton(ctx, botID, button.URL)
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrTelegramLoginURLInvalid) || errors.Is(err, domain.ErrTelegramLoginOriginNotAllowed) {
|
||||
return domain.ErrButtonURLInvalid
|
||||
}
|
||||
if errors.Is(err, domain.ErrTelegramLoginClientDisabled) {
|
||||
return domain.ErrButtonInvalid
|
||||
}
|
||||
return err
|
||||
}
|
||||
button.URL = normalized
|
||||
button.LoginBotUserID = botID
|
||||
button.LoginBotUsername = ""
|
||||
button.ButtonID = flatID
|
||||
flatID++
|
||||
}
|
||||
}
|
||||
return domain.ValidateReplyMarkup(markup)
|
||||
}
|
||||
|
||||
// P3 reply_markup 错误码(对齐官方)。
|
||||
func buttonDataInvalidErr() error { return tgerr.New(400, "BUTTON_DATA_INVALID") }
|
||||
func buttonInvalidErr() error { return tgerr.New(400, "BUTTON_INVALID") }
|
||||
|
|
@ -175,21 +265,23 @@ func domainReplyKeyboardButton(button tg.KeyboardButtonClass) (domain.MarkupButt
|
|||
|
||||
func domainInlineMarkup(inline *tg.ReplyInlineMarkup) (*domain.MessageReplyMarkup, error) {
|
||||
out := &domain.MessageReplyMarkup{Type: domain.MessageReplyMarkupInline, Inline: make([][]domain.MarkupButton, 0, len(inline.Rows))}
|
||||
buttonID := 0
|
||||
for _, row := range inline.Rows {
|
||||
domainRow := make([]domain.MarkupButton, 0, len(row.Buttons))
|
||||
for _, btn := range row.Buttons {
|
||||
db, err := domainMarkupButton(btn)
|
||||
db, err := domainMarkupButton(btn, buttonID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
domainRow = append(domainRow, db)
|
||||
buttonID++
|
||||
}
|
||||
out.Inline = append(out.Inline, domainRow)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func domainMarkupButton(btn tg.KeyboardButtonClass) (domain.MarkupButton, error) {
|
||||
func domainMarkupButton(btn tg.KeyboardButtonClass, buttonID int) (domain.MarkupButton, error) {
|
||||
style, icon, err := domainMarkupButtonStyle(btn)
|
||||
if err != nil {
|
||||
return domain.MarkupButton{}, err
|
||||
|
|
@ -209,6 +301,26 @@ func domainMarkupButton(btn tg.KeyboardButtonClass) (domain.MarkupButton, error)
|
|||
Type: domain.MarkupButtonURL, Text: b.Text, URL: b.URL,
|
||||
Style: style, IconCustomEmojiID: icon,
|
||||
}, nil
|
||||
case *tg.InputKeyboardButtonURLAuth:
|
||||
botUserID := int64(0)
|
||||
switch bot := b.Bot.(type) {
|
||||
case nil, *tg.InputUserEmpty, *tg.InputUserSelf:
|
||||
case *tg.InputUser:
|
||||
botUserID = bot.UserID
|
||||
default:
|
||||
return domain.MarkupButton{}, domain.ErrButtonInvalid
|
||||
}
|
||||
return domain.MarkupButton{
|
||||
Type: domain.MarkupButtonLoginURL, Text: b.Text, URL: b.URL,
|
||||
ForwardText: b.FwdText, ButtonID: buttonID, LoginBotUserID: botUserID,
|
||||
RequestWriteAccess: b.RequestWriteAccess, Style: style, IconCustomEmojiID: icon,
|
||||
}, nil
|
||||
case *tg.KeyboardButtonURLAuth:
|
||||
return domain.MarkupButton{
|
||||
Type: domain.MarkupButtonLoginURL, Text: b.Text, URL: b.URL,
|
||||
ForwardText: b.FwdText, ButtonID: b.ButtonID,
|
||||
Style: style, IconCustomEmojiID: icon,
|
||||
}, nil
|
||||
case *tg.KeyboardButtonWebView:
|
||||
return domain.MarkupButton{Type: domain.MarkupButtonWebView, Text: b.Text, URL: b.URL, Style: style, IconCustomEmojiID: icon}, nil
|
||||
case *tg.KeyboardButtonSwitchInline:
|
||||
|
|
@ -322,6 +434,15 @@ func tgMarkupButton(btn domain.MarkupButton) tg.KeyboardButtonClass {
|
|||
out.SetStyle(style)
|
||||
}
|
||||
return out
|
||||
case domain.MarkupButtonLoginURL:
|
||||
out := &tg.KeyboardButtonURLAuth{Text: btn.Text, URL: btn.URL, ButtonID: btn.ButtonID}
|
||||
if btn.ForwardText != "" {
|
||||
out.SetFwdText(btn.ForwardText)
|
||||
}
|
||||
if style, ok := tgMarkupButtonStyle(btn); ok {
|
||||
out.SetStyle(style)
|
||||
}
|
||||
return out
|
||||
case domain.MarkupButtonWebView:
|
||||
out := &tg.KeyboardButtonWebView{Text: btn.Text, URL: btn.URL}
|
||||
if style, ok := tgMarkupButtonStyle(btn); ok {
|
||||
|
|
|
|||
|
|
@ -71,6 +71,26 @@ func TestInlineButtonStyleTLDomainRoundTrip(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestLoginURLButtonTLDomainProjection(t *testing.T) {
|
||||
button := &tg.InputKeyboardButtonURLAuth{
|
||||
Text: "Log in", URL: "https://example.com/login", Bot: &tg.InputUser{UserID: 9001, AccessHash: 77},
|
||||
}
|
||||
button.SetRequestWriteAccess(true)
|
||||
button.SetFwdText("Open login")
|
||||
markup, err := domainReplyMarkupForSender(&tg.ReplyInlineMarkup{Rows: []tg.KeyboardButtonRow{{Buttons: []tg.KeyboardButtonClass{button}}}}, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := markup.Inline[0][0]
|
||||
if got.Type != domain.MarkupButtonLoginURL || got.LoginBotUserID != 9001 || !got.RequestWriteAccess || got.ForwardText != "Open login" || got.ButtonID != 0 {
|
||||
t.Fatalf("domain login_url = %#v", got)
|
||||
}
|
||||
wire, ok := tgReplyMarkup(markup).(*tg.ReplyInlineMarkup).Rows[0].Buttons[0].(*tg.KeyboardButtonURLAuth)
|
||||
if !ok || wire.Text != "Log in" || wire.URL != "https://example.com/login" || wire.ButtonID != 0 || wire.FwdText != "Open login" {
|
||||
t.Fatalf("wire login_url = %#v", wire)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplyKeyboardHideAndForceReplyTLDomainRoundTrip(t *testing.T) {
|
||||
hide, err := domainOutgoingReplyMarkupForSender(&tg.ReplyKeyboardHide{Selective: true}, true)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -241,6 +241,24 @@ type UsersService interface {
|
|||
ByIDs(ctx context.Context, currentUserID int64, userIDs []int64) ([]domain.User, error)
|
||||
}
|
||||
|
||||
// TelegramLoginService is the domain-only boundary shared by the MTProto RPC
|
||||
// edge and the public OIDC provider. PostgreSQL remains authoritative for all
|
||||
// consent transitions; the RPC layer only projects domain state to TL.
|
||||
type TelegramLoginService interface {
|
||||
ValidateMessageButton(ctx context.Context, botUserID int64, rawURL string) (normalizedURL, domainName string, err error)
|
||||
AuthorizeMessageButton(ctx context.Context, params domain.TelegramLoginMessageButtonAuthorization) (domain.TelegramLoginMessageButtonResult, error)
|
||||
RequestByDeepLink(ctx context.Context, deepLink string) (domain.TelegramLoginRequest, error)
|
||||
RequestByDeepLinkForOrigin(ctx context.Context, deepLink, inAppOrigin string) (domain.TelegramLoginRequest, error)
|
||||
CheckMatchCode(ctx context.Context, deepLink, selected string) (bool, error)
|
||||
Approve(ctx context.Context, deepLink string, identity domain.TelegramLoginIdentitySnapshot, writeAllowed, phoneShared bool, matchCode string) (domain.TelegramLoginRequest, domain.TelegramLoginWebAuthorization, error)
|
||||
FinalizeRedirectByDeepLink(ctx context.Context, deepLink string) (string, error)
|
||||
FinalizeInAppRedirectByDeepLink(ctx context.Context, deepLink string) (string, error)
|
||||
Decline(ctx context.Context, deepLink string, userID int64) (domain.TelegramLoginRequest, error)
|
||||
ListWebAuthorizations(ctx context.Context, userID int64) ([]domain.TelegramLoginWebAuthorization, error)
|
||||
RevokeWebAuthorization(ctx context.Context, userID, hash int64) error
|
||||
RevokeAllWebAuthorizations(ctx context.Context, userID int64) (int64, error)
|
||||
}
|
||||
|
||||
// BatchViewerUsersResolver 是 UsersService 的可选能力:跨多个 viewer 一次性投影同一组 user
|
||||
// (fan-out 模板化,把 per-recipient 的 ByIDs(=ForViewer) 折叠成 O(owner) 查询)。结果按 viewer
|
||||
// 与 ByIDs(viewer, ids) 字节等价(personal photo overlay 除外,见 users.ByIDsForViewers)。
|
||||
|
|
@ -873,6 +891,7 @@ type Deps struct {
|
|||
EphemeralPush store.EphemeralPushBroker
|
||||
EphemeralReports store.EphemeralReportStore
|
||||
Users UsersService
|
||||
TelegramLogin TelegramLoginService
|
||||
Updates UpdatesService
|
||||
BootstrapUpdates store.BootstrapUpdateJobStore
|
||||
BotAPIUpdates store.BotAPIUpdateStore
|
||||
|
|
|
|||
|
|
@ -35,6 +35,9 @@ func (r *Router) onMessagesSavePreparedInlineMessage(ctx context.Context, req *t
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := r.prepareTelegramLoginMarkup(ctx, botID, result.ReplyMarkup); err != nil {
|
||||
return nil, replyMarkupErr(err)
|
||||
}
|
||||
peerTypes, err := preparedInlinePeerTypesFromTG(req.PeerTypes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
@ -159,6 +162,11 @@ func (r *Router) editPrivateInlineBotMessage(ctx context.Context, botID int64, t
|
|||
setReplyMarkup = true
|
||||
}
|
||||
}
|
||||
if setReplyMarkup {
|
||||
if err := r.prepareTelegramLoginMarkup(ctx, botID, replyMarkup); err != nil {
|
||||
return false, replyMarkupErr(err)
|
||||
}
|
||||
}
|
||||
_, err = r.deps.Messages.EditMessage(ctx, target.OwnerUserID, domain.EditMessageRequest{
|
||||
OwnerUserID: target.OwnerUserID,
|
||||
Peer: target.Peer,
|
||||
|
|
@ -246,6 +254,11 @@ func (r *Router) editChannelInlineBotMessage(ctx context.Context, botID int64, t
|
|||
setReplyMarkup = true
|
||||
}
|
||||
}
|
||||
if setReplyMarkup {
|
||||
if err := r.prepareTelegramLoginMarkup(ctx, botID, replyMarkup); err != nil {
|
||||
return false, replyMarkupErr(err)
|
||||
}
|
||||
}
|
||||
res, err := r.deps.Channels.EditInlineBotMessage(ctx, botID, domain.EditChannelMessageRequest{
|
||||
UserID: target.SenderUserID,
|
||||
ChannelID: target.ChannelID,
|
||||
|
|
|
|||
|
|
@ -110,6 +110,11 @@ func (r *Router) onMessagesEditMessage(ctx context.Context, req *tg.MessagesEdit
|
|||
setReplyMarkup = true
|
||||
}
|
||||
}
|
||||
if setReplyMarkup {
|
||||
if err := r.validateReplyMarkupForPeer(ctx, userID, peer, replyMarkup); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if peer.Type == domain.PeerTypeChannel {
|
||||
if r.deps.Channels == nil {
|
||||
return nil, peerIDInvalidErr()
|
||||
|
|
@ -125,6 +130,8 @@ func (r *Router) onMessagesEditMessage(ctx context.Context, req *tg.MessagesEdit
|
|||
Message: message,
|
||||
Entities: domainMessageEntitiesForViewer(userID, entities),
|
||||
MentionUserIDs: mentionUserIDs,
|
||||
SetReplyMarkup: setReplyMarkup,
|
||||
ReplyMarkup: replyMarkup,
|
||||
SetRichMessage: replaceRichMessage,
|
||||
RichMessage: richMessage,
|
||||
EditDate: int(r.clock.Now().Unix()),
|
||||
|
|
|
|||
|
|
@ -11,6 +11,18 @@ import (
|
|||
|
||||
// registerMessages 注册 messages.* RPC handler。
|
||||
func (r *Router) registerMessages(d *tlprofile.Dispatcher) {
|
||||
registerRPC[*tg.MessagesRequestURLAuthRequest](d, tlprofile.SemanticMethodMessagesRequestURLAuth, func(ctx context.Context, req *tg.MessagesRequestURLAuthRequest) (any, error) {
|
||||
return r.onMessagesRequestURLAuth(ctx, req)
|
||||
})
|
||||
registerRPC[*tg.MessagesAcceptURLAuthRequest](d, tlprofile.SemanticMethodMessagesAcceptURLAuth, func(ctx context.Context, req *tg.MessagesAcceptURLAuthRequest) (any, error) {
|
||||
return r.onMessagesAcceptURLAuth(ctx, req)
|
||||
})
|
||||
registerRPC[*tg.MessagesDeclineURLAuthRequest](d, tlprofile.SemanticMethodMessagesDeclineURLAuth, func(ctx context.Context, req *tg.MessagesDeclineURLAuthRequest) (any, error) {
|
||||
return r.onMessagesDeclineURLAuth(ctx, req.URL)
|
||||
})
|
||||
registerRPC[*tg.MessagesCheckURLAuthMatchCodeRequest](d, tlprofile.SemanticMethodMessagesCheckURLAuthMatchCode, func(ctx context.Context, req *tg.MessagesCheckURLAuthMatchCodeRequest) (any, error) {
|
||||
return r.onMessagesCheckURLAuthMatchCode(ctx, req.URL, req.MatchCode)
|
||||
})
|
||||
registerRPC[*tg.MessagesReceivedMessagesRequest](d, tlprofile.SemanticMethodMessagesReceivedMessages, func(ctx context.Context, layerRequest *tg.MessagesReceivedMessagesRequest) (any, error) {
|
||||
return r.onMessagesReceivedMessages(ctx, layerRequest.
|
||||
MaxID)
|
||||
|
|
|
|||
|
|
@ -314,6 +314,9 @@ func (r *Router) onMessagesSendWebViewResultMessage(ctx context.Context, req *tg
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := r.prepareTelegramLoginMarkup(ctx, botID, result.ReplyMarkup); err != nil {
|
||||
return nil, replyMarkupErr(err)
|
||||
}
|
||||
if err := r.sendWebViewDomainResultMessage(ctx, botID, req.BotQueryID, result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -336,6 +339,9 @@ func (r *Router) AnswerWebAppQueryFromBotAPI(ctx context.Context, botID int64, b
|
|||
} else if !found {
|
||||
return "", userBotRequiredErr()
|
||||
}
|
||||
if err := r.prepareTelegramLoginMarkup(ctx, botID, result.ReplyMarkup); err != nil {
|
||||
return "", replyMarkupErr(err)
|
||||
}
|
||||
if err := r.sendWebViewDomainResultMessage(ctx, botID, botQueryID, result); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
|
@ -362,6 +368,9 @@ func (r *Router) SavePreparedInlineMessageFromBotAPI(ctx context.Context, botID,
|
|||
} else if !found {
|
||||
return "", 0, userIDInvalidErr()
|
||||
}
|
||||
if err := r.prepareTelegramLoginMarkup(ctx, botID, result.ReplyMarkup); err != nil {
|
||||
return "", 0, replyMarkupErr(err)
|
||||
}
|
||||
id, expireDate := r.inlines.savePreparedInlineContext(ctx, r.clock.Now(), botID, userID, result, peerTypes)
|
||||
return id, expireDate, nil
|
||||
}
|
||||
|
|
|
|||
434
internal/rpc/telegram_login.go
Normal file
434
internal/rpc/telegram_login.go
Normal file
|
|
@ -0,0 +1,434 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"math"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
"github.com/iamxvbaba/td/tgerr"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func telegramLoginOAuthInvalidErr() error { return tgerr.New(500, "OAUTH_REQUEST_INVALID") }
|
||||
func telegramLoginURLExpiredErr() error { return tgerr.New(400, "URL_EXPIRED") }
|
||||
func telegramLoginURLInvalidErr() error { return tgerr.New(400, "URL_INVALID") }
|
||||
func telegramLoginHashInvalidErr() error { return tgerr.New(400, "HASH_INVALID") }
|
||||
|
||||
func telegramLoginRPCError(err error) error {
|
||||
switch {
|
||||
case errors.Is(err, domain.ErrTelegramLoginRequestExpired):
|
||||
return telegramLoginURLExpiredErr()
|
||||
case errors.Is(err, domain.ErrTelegramLoginURLInvalid):
|
||||
return telegramLoginURLInvalidErr()
|
||||
case errors.Is(err, domain.ErrTelegramLoginMatchCodeInvalid),
|
||||
errors.Is(err, domain.ErrTelegramLoginRequestInvalid),
|
||||
errors.Is(err, domain.ErrTelegramLoginRequestConflict),
|
||||
errors.Is(err, domain.ErrTelegramLoginClientDisabled),
|
||||
errors.Is(err, domain.ErrTelegramLoginOriginNotAllowed),
|
||||
errors.Is(err, domain.ErrTelegramLoginRedirectNotAllowed),
|
||||
errors.Is(err, domain.ErrTelegramLoginScopeInvalid),
|
||||
errors.Is(err, domain.ErrTelegramLoginAuthorizationsTooMany):
|
||||
return telegramLoginOAuthInvalidErr()
|
||||
default:
|
||||
return internalErr()
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Router) requireTelegramLoginUser(ctx context.Context) (int64, error) {
|
||||
userID, _, err := r.currentUserID(ctx)
|
||||
if err != nil || userID <= 0 || r.deps.TelegramLogin == nil || r.deps.Users == nil {
|
||||
return 0, internalErr()
|
||||
}
|
||||
self, err := r.deps.Users.Self(ctx, userID)
|
||||
if err != nil || self.Bot || self.Deleted {
|
||||
return 0, telegramLoginOAuthInvalidErr()
|
||||
}
|
||||
return userID, nil
|
||||
}
|
||||
|
||||
func (r *Router) telegramLoginRequestResult(ctx context.Context, viewerUserID int64, request domain.TelegramLoginRequest, deepLink string) (tg.URLAuthResultClass, error) {
|
||||
switch request.Status {
|
||||
case domain.TelegramLoginRequestApproved:
|
||||
if request.AuthorizedUserID != viewerUserID {
|
||||
return nil, telegramLoginOAuthInvalidErr()
|
||||
}
|
||||
return r.telegramLoginAcceptedResult(ctx, request, deepLink)
|
||||
case domain.TelegramLoginRequestPending:
|
||||
// Continue below.
|
||||
case domain.TelegramLoginRequestDeclined, domain.TelegramLoginRequestExpired:
|
||||
return nil, telegramLoginURLExpiredErr()
|
||||
default:
|
||||
return nil, telegramLoginOAuthInvalidErr()
|
||||
}
|
||||
bot, found, err := r.deps.Users.ByID(ctx, viewerUserID, request.BotUserID)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
if !found || !bot.Bot || bot.Deleted {
|
||||
return nil, telegramLoginOAuthInvalidErr()
|
||||
}
|
||||
botTL := r.withBotProfileFlags(ctx, r.tgUser(bot))
|
||||
out := &tg.URLAuthResultRequest{
|
||||
RequestWriteAccess: request.Requests(domain.TelegramLoginScopeBotAccess),
|
||||
RequestPhoneNumber: request.Requests(domain.TelegramLoginScopePhone),
|
||||
MatchCodesFirst: request.MatchCodesFirst,
|
||||
IsApp: request.IsApp,
|
||||
Bot: botTL,
|
||||
Domain: request.Domain,
|
||||
}
|
||||
// OAuth requests carry the complete device tuple. Keep the four fields on
|
||||
// their shared flag together so old exact-layer codecs never see a partial
|
||||
// conditional shape.
|
||||
if request.Browser != "" && request.Platform != "" && request.IP != "" && request.Region != "" {
|
||||
out.SetBrowser(request.Browser)
|
||||
out.SetPlatform(request.Platform)
|
||||
out.SetIP(request.IP)
|
||||
out.SetRegion(request.Region)
|
||||
}
|
||||
if len(request.MatchCodes) > 0 {
|
||||
out.SetMatchCodes(append([]string(nil), request.MatchCodes...))
|
||||
}
|
||||
if request.UserIDHint > 0 {
|
||||
out.SetUserIDHint(request.UserIDHint)
|
||||
}
|
||||
if request.IsApp && request.VerifiedAppName != "" {
|
||||
out.SetVerifiedAppName(request.VerifiedAppName)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *Router) telegramLoginAcceptedResult(ctx context.Context, request domain.TelegramLoginRequest, deepLink string) (tg.URLAuthResultClass, error) {
|
||||
accepted := &tg.URLAuthResultAccepted{}
|
||||
switch {
|
||||
case request.Source == domain.TelegramLoginRequestNative && request.IsApp:
|
||||
redirectURL, err := r.deps.TelegramLogin.FinalizeRedirectByDeepLink(ctx, deepLink)
|
||||
if err != nil {
|
||||
return nil, telegramLoginRPCError(err)
|
||||
}
|
||||
accepted.SetURL(redirectURL)
|
||||
case request.Source == domain.TelegramLoginRequestMiniApp:
|
||||
resultURL, err := r.deps.TelegramLogin.FinalizeInAppRedirectByDeepLink(ctx, deepLink)
|
||||
if err != nil {
|
||||
return nil, telegramLoginRPCError(err)
|
||||
}
|
||||
accepted.SetURL(resultURL)
|
||||
}
|
||||
return accepted, nil
|
||||
}
|
||||
|
||||
func (r *Router) onMessagesRequestURLAuth(ctx context.Context, req *tg.MessagesRequestURLAuthRequest) (tg.URLAuthResultClass, error) {
|
||||
userID, err := r.requireTelegramLoginUser(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, hasPeer := req.GetPeer()
|
||||
urlValue, hasURL := req.GetURL()
|
||||
_, hasOrigin := req.GetInAppOrigin()
|
||||
if hasPeer == hasURL || (!hasPeer && strings.TrimSpace(urlValue) == "") || hasOrigin && !hasURL {
|
||||
return nil, telegramLoginOAuthInvalidErr()
|
||||
}
|
||||
if hasPeer {
|
||||
button, peer, err := r.telegramLoginButtonFromMessage(ctx, userID, req.Peer, req.MsgID, req.ButtonID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u, err := url.Parse(button.URL)
|
||||
if err != nil || u.Hostname() == "" {
|
||||
return nil, telegramLoginURLInvalidErr()
|
||||
}
|
||||
request := domain.TelegramLoginRequest{
|
||||
BotUserID: button.LoginBotUserID, Source: domain.TelegramLoginRequestMessageButton,
|
||||
ResponseType: "legacy_url", RedirectURI: button.URL, Domain: u.Hostname(),
|
||||
Scopes: []domain.TelegramLoginScope{domain.TelegramLoginScopeOpenID, domain.TelegramLoginScopeProfile},
|
||||
PeerType: peer.Type, PeerID: peer.ID, MessageID: req.MsgID, ButtonID: req.ButtonID,
|
||||
Status: domain.TelegramLoginRequestPending,
|
||||
}
|
||||
if button.RequestWriteAccess {
|
||||
request.Scopes = append(request.Scopes, domain.TelegramLoginScopeBotAccess)
|
||||
}
|
||||
return r.telegramLoginRequestResult(ctx, userID, request, "")
|
||||
}
|
||||
if hasOrigin && req.InAppOrigin == "" {
|
||||
return nil, telegramLoginURLInvalidErr()
|
||||
}
|
||||
request, err := r.deps.TelegramLogin.RequestByDeepLinkForOrigin(ctx, urlValue, req.InAppOrigin)
|
||||
if err != nil {
|
||||
return nil, telegramLoginRPCError(err)
|
||||
}
|
||||
return r.telegramLoginRequestResult(ctx, userID, request, urlValue)
|
||||
}
|
||||
|
||||
func (r *Router) onMessagesAcceptURLAuth(ctx context.Context, req *tg.MessagesAcceptURLAuthRequest) (tg.URLAuthResultClass, error) {
|
||||
userID, err := r.requireTelegramLoginUser(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, hasPeer := req.GetPeer()
|
||||
deepLink, hasURL := req.GetURL()
|
||||
matchCode, hasMatchCode := req.GetMatchCode()
|
||||
if hasPeer == hasURL || (!hasPeer && strings.TrimSpace(deepLink) == "") || (hasMatchCode && matchCode == "") {
|
||||
return nil, telegramLoginOAuthInvalidErr()
|
||||
}
|
||||
if hasPeer {
|
||||
if hasMatchCode || req.SharePhoneNumber {
|
||||
return nil, telegramLoginOAuthInvalidErr()
|
||||
}
|
||||
button, peer, err := r.telegramLoginButtonFromMessage(ctx, userID, req.Peer, req.MsgID, req.ButtonID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if r.deps.Bots == nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
profile, found, err := r.deps.Bots.BotInfo(ctx, button.LoginBotUserID)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
if !found || profile.TokenSecret == "" {
|
||||
return nil, telegramLoginOAuthInvalidErr()
|
||||
}
|
||||
self, err := r.deps.Users.Self(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
identity := r.telegramLoginIdentity(self)
|
||||
result, err := r.deps.TelegramLogin.AuthorizeMessageButton(ctx, domain.TelegramLoginMessageButtonAuthorization{
|
||||
UserID: userID, BotUserID: button.LoginBotUserID,
|
||||
BotToken: domain.FormatBotToken(button.LoginBotUserID, profile.TokenSecret), URL: button.URL,
|
||||
RequestWriteAccess: button.RequestWriteAccess, WriteAllowed: req.WriteAllowed,
|
||||
Peer: peer, MessageID: req.MsgID, ButtonID: req.ButtonID,
|
||||
Browser: "Telegram", Platform: "Telegram Client", IP: "Unknown IP", Region: "Unknown region",
|
||||
Identity: identity,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, telegramLoginRPCError(err)
|
||||
}
|
||||
accepted := &tg.URLAuthResultAccepted{}
|
||||
accepted.SetURL(result.URL)
|
||||
return accepted, nil
|
||||
}
|
||||
request, err := r.deps.TelegramLogin.RequestByDeepLink(ctx, deepLink)
|
||||
if err != nil {
|
||||
return nil, telegramLoginRPCError(err)
|
||||
}
|
||||
if request.Status == domain.TelegramLoginRequestApproved {
|
||||
if request.AuthorizedUserID == userID {
|
||||
return r.telegramLoginAcceptedResult(ctx, request, deepLink)
|
||||
}
|
||||
return nil, telegramLoginOAuthInvalidErr()
|
||||
}
|
||||
if request.Status != domain.TelegramLoginRequestPending {
|
||||
return nil, telegramLoginURLExpiredErr()
|
||||
}
|
||||
self, err := r.deps.Users.Self(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
identity := r.telegramLoginIdentity(self)
|
||||
approved, _, err := r.deps.TelegramLogin.Approve(ctx, deepLink, identity, req.WriteAllowed, req.SharePhoneNumber, matchCode)
|
||||
if err != nil {
|
||||
return nil, telegramLoginRPCError(err)
|
||||
}
|
||||
if approved.AuthorizedUserID != userID {
|
||||
return nil, telegramLoginOAuthInvalidErr()
|
||||
}
|
||||
return r.telegramLoginAcceptedResult(ctx, approved, deepLink)
|
||||
}
|
||||
|
||||
func (r *Router) telegramLoginIdentity(self domain.User) domain.TelegramLoginIdentitySnapshot {
|
||||
identity := domain.TelegramLoginIdentitySnapshot{
|
||||
UserID: self.ID, Name: strings.TrimSpace(strings.TrimSpace(self.FirstName) + " " + strings.TrimSpace(self.LastName)),
|
||||
GivenName: self.FirstName, FamilyName: self.LastName,
|
||||
PreferredUsername: self.Username, PhoneNumber: self.Phone,
|
||||
}
|
||||
if strings.TrimSpace(r.cfg.PublicBaseURL) != "" && self.Username != "" && self.PhotoID > 0 {
|
||||
identity.Picture = strings.TrimSuffix(r.cfg.PublicBaseURL, "/") + "/_public/avatar/" + url.PathEscape(self.Username) + "/" + strconv.FormatInt(self.PhotoID, 10)
|
||||
}
|
||||
return identity
|
||||
}
|
||||
|
||||
func (r *Router) telegramLoginButtonFromMessage(ctx context.Context, userID int64, inputPeer tg.InputPeerClass, messageID, buttonID int) (domain.MarkupButton, domain.Peer, error) {
|
||||
if messageID <= 0 || messageID > domain.MaxMessageBoxID || buttonID < 0 {
|
||||
return domain.MarkupButton{}, domain.Peer{}, telegramLoginOAuthInvalidErr()
|
||||
}
|
||||
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, inputPeer)
|
||||
if err != nil {
|
||||
return domain.MarkupButton{}, domain.Peer{}, err
|
||||
}
|
||||
var markup *domain.MessageReplyMarkup
|
||||
switch peer.Type {
|
||||
case domain.PeerTypeUser:
|
||||
message, found, err := r.lookupOwnerMessage(ctx, userID, messageID)
|
||||
if err != nil {
|
||||
return domain.MarkupButton{}, domain.Peer{}, internalErr()
|
||||
}
|
||||
if !found || message.Peer != peer {
|
||||
return domain.MarkupButton{}, domain.Peer{}, telegramLoginOAuthInvalidErr()
|
||||
}
|
||||
markup = message.ReplyMarkup
|
||||
case domain.PeerTypeChannel:
|
||||
if r.deps.Channels == nil {
|
||||
return domain.MarkupButton{}, domain.Peer{}, telegramLoginOAuthInvalidErr()
|
||||
}
|
||||
history, err := r.deps.Channels.GetMessages(ctx, userID, peer.ID, []int{messageID})
|
||||
if err != nil || len(history.Messages) != 1 || history.Messages[0].ID != messageID {
|
||||
return domain.MarkupButton{}, domain.Peer{}, telegramLoginOAuthInvalidErr()
|
||||
}
|
||||
markup = history.Messages[0].ReplyMarkup
|
||||
default:
|
||||
return domain.MarkupButton{}, domain.Peer{}, telegramLoginOAuthInvalidErr()
|
||||
}
|
||||
if markup == nil || markup.Kind() != domain.MessageReplyMarkupInline {
|
||||
return domain.MarkupButton{}, domain.Peer{}, telegramLoginOAuthInvalidErr()
|
||||
}
|
||||
for _, row := range markup.Inline {
|
||||
for _, button := range row {
|
||||
if button.Type == domain.MarkupButtonLoginURL && button.ButtonID == buttonID && button.LoginBotUserID > 0 {
|
||||
return button, peer, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return domain.MarkupButton{}, domain.Peer{}, telegramLoginOAuthInvalidErr()
|
||||
}
|
||||
|
||||
func (r *Router) onMessagesDeclineURLAuth(ctx context.Context, deepLink string) (bool, error) {
|
||||
userID, err := r.requireTelegramLoginUser(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if strings.TrimSpace(deepLink) == "" {
|
||||
return false, telegramLoginURLInvalidErr()
|
||||
}
|
||||
request, err := r.deps.TelegramLogin.RequestByDeepLink(ctx, deepLink)
|
||||
if err != nil {
|
||||
return false, telegramLoginRPCError(err)
|
||||
}
|
||||
if request.Status == domain.TelegramLoginRequestDeclined {
|
||||
return true, nil
|
||||
}
|
||||
if request.Status != domain.TelegramLoginRequestPending {
|
||||
return false, telegramLoginOAuthInvalidErr()
|
||||
}
|
||||
if _, err := r.deps.TelegramLogin.Decline(ctx, deepLink, userID); err != nil {
|
||||
return false, telegramLoginRPCError(err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (r *Router) onMessagesCheckURLAuthMatchCode(ctx context.Context, deepLink, matchCode string) (bool, error) {
|
||||
if _, err := r.requireTelegramLoginUser(ctx); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if strings.TrimSpace(deepLink) == "" || matchCode == "" {
|
||||
return false, telegramLoginURLInvalidErr()
|
||||
}
|
||||
ok, err := r.deps.TelegramLogin.CheckMatchCode(ctx, deepLink, matchCode)
|
||||
if err != nil {
|
||||
return false, telegramLoginRPCError(err)
|
||||
}
|
||||
return ok, nil
|
||||
}
|
||||
|
||||
func (r *Router) onAccountGetWebAuthorizations(ctx context.Context) (*tg.AccountWebAuthorizations, error) {
|
||||
if r.deps.TelegramLogin == nil {
|
||||
if _, _, err := r.currentUserID(ctx); err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
return &tg.AccountWebAuthorizations{Authorizations: []tg.WebAuthorization{}, Users: []tg.UserClass{}}, nil
|
||||
}
|
||||
userID, err := r.requireTelegramLoginUser(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
authorizations, err := r.deps.TelegramLogin.ListWebAuthorizations(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
result := &tg.AccountWebAuthorizations{
|
||||
Authorizations: make([]tg.WebAuthorization, 0, len(authorizations)),
|
||||
Users: []tg.UserClass{},
|
||||
}
|
||||
botIDs := make([]int64, 0, len(authorizations))
|
||||
seenBots := make(map[int64]struct{}, len(authorizations))
|
||||
for _, authorization := range authorizations {
|
||||
result.Authorizations = append(result.Authorizations, tg.WebAuthorization{
|
||||
Hash: authorization.Hash, BotID: authorization.BotUserID, Domain: authorization.Domain,
|
||||
Browser: authorization.Browser, Platform: authorization.Platform,
|
||||
DateCreated: telegramLoginUnixInt(authorization.CreatedAt), DateActive: telegramLoginUnixInt(authorization.LastActiveAt),
|
||||
IP: authorization.IP, Region: authorization.Region,
|
||||
})
|
||||
if _, duplicate := seenBots[authorization.BotUserID]; !duplicate {
|
||||
seenBots[authorization.BotUserID] = struct{}{}
|
||||
botIDs = append(botIDs, authorization.BotUserID)
|
||||
}
|
||||
}
|
||||
if len(botIDs) > 0 {
|
||||
bots, err := r.deps.Users.ByIDs(ctx, userID, botIDs)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
for _, bot := range bots {
|
||||
if bot.Bot && !bot.Deleted {
|
||||
result.Users = append(result.Users, r.withBotProfileFlags(ctx, r.tgUser(bot)))
|
||||
}
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *Router) onAccountResetWebAuthorization(ctx context.Context, hash int64) (bool, error) {
|
||||
if r.deps.TelegramLogin == nil {
|
||||
if _, _, err := r.currentUserID(ctx); err != nil {
|
||||
return false, internalErr()
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
userID, err := r.requireTelegramLoginUser(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if hash == 0 {
|
||||
return false, telegramLoginHashInvalidErr()
|
||||
}
|
||||
if err := r.deps.TelegramLogin.RevokeWebAuthorization(ctx, userID, hash); err != nil {
|
||||
if errors.Is(err, domain.ErrTelegramLoginWebAuthHashInvalid) {
|
||||
return false, telegramLoginHashInvalidErr()
|
||||
}
|
||||
return false, internalErr()
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (r *Router) onAccountResetWebAuthorizations(ctx context.Context) (bool, error) {
|
||||
if r.deps.TelegramLogin == nil {
|
||||
if _, _, err := r.currentUserID(ctx); err != nil {
|
||||
return false, internalErr()
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
userID, err := r.requireTelegramLoginUser(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if _, err := r.deps.TelegramLogin.RevokeAllWebAuthorizations(ctx, userID); err != nil {
|
||||
return false, internalErr()
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func telegramLoginUnixInt(value time.Time) int {
|
||||
unix := value.Unix()
|
||||
if unix < 0 {
|
||||
return 0
|
||||
}
|
||||
if unix > math.MaxInt32 {
|
||||
return math.MaxInt32
|
||||
}
|
||||
return int(unix)
|
||||
}
|
||||
380
internal/rpc/telegram_login_rpc_test.go
Normal file
380
internal/rpc/telegram_login_rpc_test.go
Normal file
|
|
@ -0,0 +1,380 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/iamxvbaba/td/clock"
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
"github.com/iamxvbaba/td/tlprofile"
|
||||
"go.uber.org/zap/zaptest"
|
||||
|
||||
telegramloginapp "telesrv/internal/app/telegramlogin"
|
||||
appusers "telesrv/internal/app/users"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
type telegramLoginBotPermissionAdapter struct{ bots BotsService }
|
||||
|
||||
func (a telegramLoginBotPermissionAdapter) AllowBotSendMessage(ctx context.Context, botUserID, userID int64, fromRequest bool) (bool, error) {
|
||||
return a.bots.AllowSendMessage(ctx, userID, botUserID, fromRequest)
|
||||
}
|
||||
|
||||
type telegramLoginRPCFixture struct {
|
||||
ctx context.Context
|
||||
service *telegramloginapp.Service
|
||||
router *Router
|
||||
user domain.User
|
||||
intruder domain.User
|
||||
bot domain.User
|
||||
client telegramloginapp.ClientCredentials
|
||||
redirect string
|
||||
}
|
||||
|
||||
func newTelegramLoginRPCFixture(t *testing.T) *telegramLoginRPCFixture {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
users := memory.NewUserStore()
|
||||
user, err := users.Create(ctx, domain.User{Phone: "+15551001", FirstName: "Alice", LastName: "Example", Username: "alice", AccessHash: 11})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
intruder, err := users.Create(ctx, domain.User{Phone: "+15551002", FirstName: "Mallory", Username: "mallory", AccessHash: 13})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bot, err := users.Create(ctx, domain.User{FirstName: "Login Bot", Username: "login_rpc_bot", AccessHash: 12, Bot: true, BotInfoVersion: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sealKey := make([]byte, 32)
|
||||
sealKey[0] = 3
|
||||
sealer, err := telegramloginapp.NewCodeSealer("test", map[string][]byte{"test": sealKey})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pepper := make([]byte, 32)
|
||||
pepper[0] = 4
|
||||
service, err := telegramloginapp.NewService(memory.NewTelegramLoginStore(nil), sealer, telegramloginapp.Config{
|
||||
Issuer: "https://oauth.test", AppScheme: "telesrv", ClientSecretPepper: pepper,
|
||||
Now: func() time.Time { return time.Unix(1_780_000_000, 0).UTC() },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
client, err := service.CreateClient(ctx, bot.ID, domain.TelegramLoginSigningRS256)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
redirect := "https://rp.test/callback"
|
||||
if _, err := service.AddAllowedURL(ctx, bot.ID, domain.TelegramLoginAllowedRedirectURI, redirect); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := service.AddAllowedURL(ctx, bot.ID, domain.TelegramLoginAllowedWebOrigin, "https://rp.test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
router := New(Config{}, Deps{Users: appusers.NewService(users), TelegramLogin: service}, zaptest.NewLogger(t), clock.System)
|
||||
return &telegramLoginRPCFixture{ctx: WithUserID(ctx, user.ID), service: service, router: router, user: user, intruder: intruder, bot: bot, client: client, redirect: redirect}
|
||||
}
|
||||
|
||||
func (f *telegramLoginRPCFixture) authorization(t *testing.T, match bool) telegramloginapp.CreatedAuthorization {
|
||||
t.Helper()
|
||||
challenge, err := telegramloginapp.PKCEChallenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
created, err := f.service.CreateAuthorization(f.ctx, telegramloginapp.CreateAuthorizationParams{
|
||||
ClientID: f.client.Client.ClientID, RedirectURI: f.redirect, ResponseType: "code",
|
||||
Scope: "openid profile telegram:bot_access", CodeChallenge: challenge, CodeChallengeMethod: "S256",
|
||||
IncludeMatchCodes: match, MatchCodesFirst: match,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return created
|
||||
}
|
||||
|
||||
func TestTelegramLoginRPCsAcrossExactLayerProfiles(t *testing.T) {
|
||||
for profile := tlprofile.Profile225; profile <= tlprofile.Profile228; profile++ {
|
||||
t.Run(fmt.Sprintf("layer_%d", profile), func(t *testing.T) {
|
||||
f := newTelegramLoginRPCFixture(t)
|
||||
|
||||
approve := f.authorization(t, true)
|
||||
// TDesktop normalizes the configured telesrv:// launcher to the
|
||||
// official internal tg://oauth form before invoking MTProto.
|
||||
canonicalURL := strings.Replace(approve.DeepLink, "telesrv://", "tg://", 1)
|
||||
request := &tg.MessagesRequestURLAuthRequest{}
|
||||
request.SetURL(canonicalURL)
|
||||
result, method := dispatchExactLayerRPCTest(t, f.router, f.ctx, profile, request)
|
||||
if method != "messages.requestUrlAuth" {
|
||||
t.Fatalf("method = %q", method)
|
||||
}
|
||||
prompt, ok := dispatchCanonicalValue(result).(*tg.URLAuthResultRequest)
|
||||
if !ok || prompt.Bot.GetID() != f.bot.ID || !prompt.RequestWriteAccess || len(prompt.MatchCodes) != 5 {
|
||||
t.Fatalf("request result = %#v", dispatchCanonicalValue(result))
|
||||
}
|
||||
|
||||
checked, method := dispatchExactLayerRPCTest(t, f.router, f.ctx, profile, &tg.MessagesCheckURLAuthMatchCodeRequest{
|
||||
URL: canonicalURL, MatchCode: approve.Request.MatchCode,
|
||||
})
|
||||
if method != "messages.checkUrlAuthMatchCode" || dispatchCanonicalValue(checked) != true {
|
||||
t.Fatalf("check result = %#v method=%q", dispatchCanonicalValue(checked), method)
|
||||
}
|
||||
accept := &tg.MessagesAcceptURLAuthRequest{WriteAllowed: true}
|
||||
accept.SetURL(canonicalURL)
|
||||
accept.SetMatchCode(approve.Request.MatchCode)
|
||||
accepted, method := dispatchExactLayerRPCTest(t, f.router, f.ctx, profile, accept)
|
||||
if method != "messages.acceptUrlAuth" {
|
||||
t.Fatalf("accept method = %q", method)
|
||||
}
|
||||
if _, ok := dispatchCanonicalValue(accepted).(*tg.URLAuthResultAccepted); !ok {
|
||||
t.Fatalf("accept result = %#v", dispatchCanonicalValue(accepted))
|
||||
}
|
||||
|
||||
decline := f.authorization(t, false)
|
||||
declined, method := dispatchExactLayerRPCTest(t, f.router, f.ctx, profile, &tg.MessagesDeclineURLAuthRequest{URL: decline.DeepLink})
|
||||
if method != "messages.declineUrlAuth" || dispatchCanonicalValue(declined) != true {
|
||||
t.Fatalf("decline result = %#v method=%q", dispatchCanonicalValue(declined), method)
|
||||
}
|
||||
|
||||
listed, method := dispatchExactLayerRPCTest(t, f.router, f.ctx, profile, &tg.AccountGetWebAuthorizationsRequest{})
|
||||
web, ok := dispatchCanonicalValue(listed).(*tg.AccountWebAuthorizations)
|
||||
if method != "account.getWebAuthorizations" || !ok || len(web.Authorizations) != 1 || web.Authorizations[0].BotID != f.bot.ID {
|
||||
t.Fatalf("getWebAuthorizations = %#v method=%q", dispatchCanonicalValue(listed), method)
|
||||
}
|
||||
reset, method := dispatchExactLayerRPCTest(t, f.router, f.ctx, profile, &tg.AccountResetWebAuthorizationRequest{Hash: web.Authorizations[0].Hash})
|
||||
if method != "account.resetWebAuthorization" || dispatchCanonicalValue(reset) != true {
|
||||
t.Fatalf("resetWebAuthorization = %#v method=%q", dispatchCanonicalValue(reset), method)
|
||||
}
|
||||
|
||||
const nativeCallback = "bedolaga://telegram-login"
|
||||
if _, err := f.service.AddNativeApp(f.ctx, f.bot.ID, domain.TelegramLoginNativeAndroid,
|
||||
"dev.bedolaga.demo", strings.Repeat("A", 64), nativeCallback, "Bedolaga Android Demo"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
challenge, err := telegramloginapp.PKCEChallenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
native, err := f.service.CreateAuthorization(f.ctx, telegramloginapp.CreateAuthorizationParams{
|
||||
ClientID: f.client.Client.ClientID, RedirectURI: nativeCallback, ResponseType: "code",
|
||||
Scope: "profile", CodeChallenge: challenge, CodeChallengeMethod: "S256",
|
||||
NativePlatform: domain.TelegramLoginNativeAndroid, IncludeMatchCodes: true, MatchCodesFirst: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
nativeRequest := &tg.MessagesRequestURLAuthRequest{}
|
||||
nativeRequest.SetURL(native.DeepLink)
|
||||
nativeResult, _ := dispatchExactLayerRPCTest(t, f.router, f.ctx, profile, nativeRequest)
|
||||
nativePrompt, ok := dispatchCanonicalValue(nativeResult).(*tg.URLAuthResultRequest)
|
||||
if !ok || !nativePrompt.IsApp || nativePrompt.VerifiedAppName != "Bedolaga Android Demo" || len(nativePrompt.MatchCodes) != 5 {
|
||||
t.Fatalf("native request result = %#v", dispatchCanonicalValue(nativeResult))
|
||||
}
|
||||
nativeAccept := &tg.MessagesAcceptURLAuthRequest{}
|
||||
nativeAccept.SetURL(native.DeepLink)
|
||||
nativeAccept.SetMatchCode(native.Request.MatchCode)
|
||||
nativeAcceptedResult, _ := dispatchExactLayerRPCTest(t, f.router, f.ctx, profile, nativeAccept)
|
||||
nativeAccepted, ok := dispatchCanonicalValue(nativeAcceptedResult).(*tg.URLAuthResultAccepted)
|
||||
if !ok || !strings.HasPrefix(nativeAccepted.URL, nativeCallback+"?code=") {
|
||||
t.Fatalf("native accepted result = %#v", dispatchCanonicalValue(nativeAcceptedResult))
|
||||
}
|
||||
nativeRetryResult, _ := dispatchExactLayerRPCTest(t, f.router, f.ctx, profile, nativeRequest)
|
||||
nativeRetry, ok := dispatchCanonicalValue(nativeRetryResult).(*tg.URLAuthResultAccepted)
|
||||
if !ok || nativeRetry.URL != nativeAccepted.URL {
|
||||
t.Fatalf("native retry result = %#v, want URL %q", dispatchCanonicalValue(nativeRetryResult), nativeAccepted.URL)
|
||||
}
|
||||
|
||||
const miniAppOrigin = "https://rp.test"
|
||||
miniApp, err := f.service.CreateAuthorization(f.ctx, telegramloginapp.CreateAuthorizationParams{
|
||||
ClientID: f.client.Client.ClientID, RedirectURI: miniAppOrigin + "/", ResponseType: "post_message",
|
||||
Scope: "openid profile", Origin: miniAppOrigin, InAppOrigin: miniAppOrigin,
|
||||
Source: domain.TelegramLoginRequestMiniApp, IncludeMatchCodes: true, MatchCodesFirst: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
miniAppRequest := &tg.MessagesRequestURLAuthRequest{}
|
||||
miniAppRequest.SetURL(miniApp.DeepLink)
|
||||
miniAppRequest.SetInAppOrigin(miniAppOrigin)
|
||||
miniAppResult, method := dispatchExactLayerRPCTest(t, f.router, f.ctx, profile, miniAppRequest)
|
||||
miniAppPrompt, ok := dispatchCanonicalValue(miniAppResult).(*tg.URLAuthResultRequest)
|
||||
if method != "messages.requestUrlAuth" || !ok || len(miniAppPrompt.MatchCodes) != 5 {
|
||||
t.Fatalf("mini-app request result = %#v method=%q", dispatchCanonicalValue(miniAppResult), method)
|
||||
}
|
||||
miniAppAccept := &tg.MessagesAcceptURLAuthRequest{}
|
||||
miniAppAccept.SetURL(miniApp.DeepLink)
|
||||
miniAppAccept.SetMatchCode(miniApp.Request.MatchCode)
|
||||
miniAppAcceptedResult, method := dispatchExactLayerRPCTest(t, f.router, f.ctx, profile, miniAppAccept)
|
||||
miniAppAccepted, ok := dispatchCanonicalValue(miniAppAcceptedResult).(*tg.URLAuthResultAccepted)
|
||||
if method != "messages.acceptUrlAuth" || !ok || !strings.HasPrefix(miniAppAccepted.URL, "https://oauth.test/inapp?token=") {
|
||||
t.Fatalf("mini-app accepted result = %#v method=%q", dispatchCanonicalValue(miniAppAcceptedResult), method)
|
||||
}
|
||||
miniAppRetryResult, _ := dispatchExactLayerRPCTest(t, f.router, f.ctx, profile, miniAppRequest)
|
||||
miniAppRetry, ok := dispatchCanonicalValue(miniAppRetryResult).(*tg.URLAuthResultAccepted)
|
||||
if !ok || miniAppRetry.URL != miniAppAccepted.URL {
|
||||
t.Fatalf("mini-app retry result = %#v, want URL %q", dispatchCanonicalValue(miniAppRetryResult), miniAppAccepted.URL)
|
||||
}
|
||||
resetAll, method := dispatchExactLayerRPCTest(t, f.router, f.ctx, profile, &tg.AccountResetWebAuthorizationsRequest{})
|
||||
if method != "account.resetWebAuthorizations" || dispatchCanonicalValue(resetAll) != true {
|
||||
t.Fatalf("resetWebAuthorizations = %#v method=%q", dispatchCanonicalValue(resetAll), method)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramLoginApprovedDeepLinkRejectsAnotherUser(t *testing.T) {
|
||||
f := newTelegramLoginRPCFixture(t)
|
||||
created := f.authorization(t, false)
|
||||
accept := &tg.MessagesAcceptURLAuthRequest{}
|
||||
accept.SetURL(created.DeepLink)
|
||||
if _, err := f.router.onMessagesAcceptURLAuth(f.ctx, accept); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := &tg.MessagesRequestURLAuthRequest{}
|
||||
request.SetURL(created.DeepLink)
|
||||
if _, err := f.router.onMessagesRequestURLAuth(WithUserID(context.Background(), f.intruder.ID), request); err == nil {
|
||||
t.Fatal("another user observed an approved deep link as accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramLoginMessageButtonRereadSignsAndGrantsWriteAccess(t *testing.T) {
|
||||
f := newBotAPIReceiveFixture(t, false)
|
||||
sealKey := make([]byte, 32)
|
||||
sealKey[0] = 7
|
||||
sealer, err := telegramloginapp.NewCodeSealer("test", map[string][]byte{"test": sealKey})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pepper := make([]byte, 32)
|
||||
pepper[0] = 8
|
||||
loginStore := memory.NewTelegramLoginStore(telegramLoginBotPermissionAdapter{bots: f.router.deps.Bots})
|
||||
login, err := telegramloginapp.NewService(loginStore, sealer, telegramloginapp.Config{
|
||||
Issuer: "https://oauth.test", AppScheme: "telesrv", ClientSecretPepper: pepper,
|
||||
Now: func() time.Time { return time.Unix(1_780_000_000, 0).UTC() },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := login.CreateClient(f.ctx, f.bot.ID, domain.TelegramLoginSigningRS256); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := login.AddAllowedURL(f.ctx, f.bot.ID, domain.TelegramLoginAllowedWebOrigin, "https://rp.test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.router.deps.TelegramLogin = login
|
||||
|
||||
markup := &domain.MessageReplyMarkup{Type: domain.MessageReplyMarkupInline, Inline: [][]domain.MarkupButton{{{
|
||||
Type: domain.MarkupButtonLoginURL, Text: "Log in", URL: "https://rp.test/login?next=%2Fhome", RequestWriteAccess: true,
|
||||
}}}}
|
||||
if _, err := f.router.BotAPISendMessage(f.ctx, f.bot.ID, f.owner.ID, "Authorize", nil, markup, false, false, 0); err != nil {
|
||||
t.Fatalf("BotAPISendMessage: %v", err)
|
||||
}
|
||||
history, err := f.messages.GetHistory(f.ctx, f.owner.ID, domain.MessageFilter{
|
||||
HasPeer: true, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: f.bot.ID}, Limit: 10,
|
||||
})
|
||||
if err != nil || len(history.Messages) == 0 {
|
||||
t.Fatalf("GetHistory: messages=%d err=%v", len(history.Messages), err)
|
||||
}
|
||||
message := history.Messages[0]
|
||||
if message.ReplyMarkup == nil || message.ReplyMarkup.Inline[0][0].LoginBotUserID != f.bot.ID {
|
||||
t.Fatalf("persisted login button = %#v", message.ReplyMarkup)
|
||||
}
|
||||
|
||||
peer := &tg.InputPeerUser{UserID: f.bot.ID, AccessHash: f.bot.AccessHash}
|
||||
request := &tg.MessagesRequestURLAuthRequest{}
|
||||
request.SetPeer(peer)
|
||||
request.SetMsgID(message.ID)
|
||||
request.SetButtonID(0)
|
||||
requested, err := f.router.onMessagesRequestURLAuth(WithUserID(f.ctx, f.owner.ID), request)
|
||||
if err != nil {
|
||||
t.Fatalf("requestUrlAuth: %v", err)
|
||||
}
|
||||
prompt, ok := requested.(*tg.URLAuthResultRequest)
|
||||
if !ok || !prompt.RequestWriteAccess || prompt.Domain != "rp.test" {
|
||||
t.Fatalf("requestUrlAuth result = %#v", requested)
|
||||
}
|
||||
accept := &tg.MessagesAcceptURLAuthRequest{}
|
||||
accept.SetWriteAllowed(true)
|
||||
accept.SetPeer(peer)
|
||||
accept.SetMsgID(message.ID)
|
||||
accept.SetButtonID(0)
|
||||
accepted, err := f.router.onMessagesAcceptURLAuth(WithUserID(f.ctx, f.owner.ID), accept)
|
||||
if err != nil {
|
||||
t.Fatalf("acceptUrlAuth: %v", err)
|
||||
}
|
||||
final, ok := accepted.(*tg.URLAuthResultAccepted)
|
||||
if !ok || final.URL == "" {
|
||||
t.Fatalf("acceptUrlAuth result = %#v", accepted)
|
||||
}
|
||||
verifyLegacyTelegramLoginURL(t, final.URL, domain.FormatBotToken(f.bot.ID, "secret"), f.owner.ID)
|
||||
if allowed, err := f.router.deps.Bots.CanSendMessage(f.ctx, f.owner.ID, f.bot.ID); err != nil || !allowed {
|
||||
t.Fatalf("bot write permission = %v,%v", allowed, err)
|
||||
}
|
||||
web, err := login.ListWebAuthorizations(f.ctx, f.owner.ID)
|
||||
if err != nil || len(web) != 1 || !web[0].BotAccessGranted || web[0].Domain != "rp.test" {
|
||||
t.Fatalf("web authorizations = %#v err=%v", web, err)
|
||||
}
|
||||
|
||||
// The server must re-read durable message state. A forged button id never
|
||||
// falls back to URL data supplied by the client.
|
||||
forged := &tg.MessagesAcceptURLAuthRequest{}
|
||||
forged.SetPeer(peer)
|
||||
forged.SetMsgID(message.ID)
|
||||
forged.SetButtonID(99)
|
||||
if _, err := f.router.onMessagesAcceptURLAuth(WithUserID(f.ctx, f.owner.ID), forged); err == nil {
|
||||
t.Fatal("forged button id was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramLoginMarkupRequiresBotSender(t *testing.T) {
|
||||
f := newTelegramLoginRPCFixture(t)
|
||||
markup := &domain.MessageReplyMarkup{Type: domain.MessageReplyMarkupInline, Inline: [][]domain.MarkupButton{{{
|
||||
Type: domain.MarkupButtonLoginURL, Text: "Log in", URL: "https://rp.test/login",
|
||||
}}}}
|
||||
if err := f.router.prepareTelegramLoginMarkup(WithUserID(f.ctx, f.user.ID), f.user.ID, markup); !errors.Is(err, domain.ErrButtonTypeInvalid) {
|
||||
t.Fatalf("ordinary user login_url error = %v, want ErrButtonTypeInvalid", err)
|
||||
}
|
||||
}
|
||||
|
||||
func verifyLegacyTelegramLoginURL(t *testing.T, raw, botToken string, wantUserID int64) {
|
||||
t.Helper()
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
query := u.Query()
|
||||
provided := query.Get("hash")
|
||||
query.Del("hash")
|
||||
if query.Get("id") != strconv.FormatInt(wantUserID, 10) || query.Get("auth_date") == "" || query.Get("next") != "/home" {
|
||||
t.Fatalf("legacy login query = %#v", query)
|
||||
}
|
||||
keys := make([]string, 0, len(query))
|
||||
for key := range query {
|
||||
if key != "next" { // Existing application query fields are not signed.
|
||||
keys = append(keys, key)
|
||||
}
|
||||
}
|
||||
sort.Strings(keys)
|
||||
lines := make([]string, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
lines = append(lines, key+"="+query.Get(key))
|
||||
}
|
||||
secret := sha256.Sum256([]byte(botToken))
|
||||
mac := hmac.New(sha256.New, secret[:])
|
||||
_, _ = mac.Write([]byte(strings.Join(lines, "\n")))
|
||||
if !hmac.Equal([]byte(strings.ToLower(provided)), []byte(hex.EncodeToString(mac.Sum(nil)))) {
|
||||
t.Fatalf("legacy login hash = %q, want %s", provided, hex.EncodeToString(mac.Sum(nil)))
|
||||
}
|
||||
}
|
||||
|
|
@ -75,6 +75,9 @@ func botFatherSeedProfile() domain.BotProfile {
|
|||
{Command: "mybots", Description: "list your bots"},
|
||||
{Command: "token", Description: "show a bot's token"},
|
||||
{Command: "revoke", Description: "revoke a bot's token"},
|
||||
{Command: "setlogin", Description: "configure Telegram Login"},
|
||||
{Command: "logininfo", Description: "show Telegram Login configuration"},
|
||||
{Command: "resetloginsecret", Description: "rotate an OIDC Client Secret"},
|
||||
{Command: "cancel", Description: "cancel the current operation"},
|
||||
{Command: "help", Description: "show help"},
|
||||
},
|
||||
|
|
|
|||
721
internal/store/memory/telegram_login.go
Normal file
721
internal/store/memory/telegram_login.go
Normal file
|
|
@ -0,0 +1,721 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
type telegramLoginBotPermissionWriter interface {
|
||||
AllowBotSendMessage(ctx context.Context, botUserID, userID int64, fromRequest bool) (bool, error)
|
||||
}
|
||||
|
||||
// TelegramLoginStore is the deterministic in-memory implementation used by
|
||||
// application and RPC tests. A single mutex makes the same aggregate changes
|
||||
// atomic; production uses PostgreSQL row locks and one transaction.
|
||||
type TelegramLoginStore struct {
|
||||
mu sync.RWMutex
|
||||
|
||||
permissions telegramLoginBotPermissionWriter
|
||||
nextURLID int64
|
||||
nextAppID int64
|
||||
nextRequestID int64
|
||||
nextCodeID int64
|
||||
|
||||
clientsByID map[string]domain.TelegramLoginClient
|
||||
clientByBot map[int64]string
|
||||
allowedURLs map[string]domain.TelegramLoginAllowedURL
|
||||
nativeApps map[int64]domain.TelegramLoginNativeApp
|
||||
requests map[int64]domain.TelegramLoginRequest
|
||||
requestToken map[string]int64
|
||||
browserToken map[string]int64
|
||||
codes map[int64]domain.TelegramLoginAuthorizationCode
|
||||
codeByHash map[string]int64
|
||||
codeByRequest map[int64]int64
|
||||
webAuths map[int64]domain.TelegramLoginWebAuthorization
|
||||
}
|
||||
|
||||
func NewTelegramLoginStore(permissions telegramLoginBotPermissionWriter) *TelegramLoginStore {
|
||||
return &TelegramLoginStore{
|
||||
permissions: permissions,
|
||||
clientsByID: make(map[string]domain.TelegramLoginClient),
|
||||
clientByBot: make(map[int64]string),
|
||||
allowedURLs: make(map[string]domain.TelegramLoginAllowedURL),
|
||||
nativeApps: make(map[int64]domain.TelegramLoginNativeApp),
|
||||
requests: make(map[int64]domain.TelegramLoginRequest),
|
||||
requestToken: make(map[string]int64),
|
||||
browserToken: make(map[string]int64),
|
||||
codes: make(map[int64]domain.TelegramLoginAuthorizationCode),
|
||||
codeByHash: make(map[string]int64),
|
||||
codeByRequest: make(map[int64]int64),
|
||||
webAuths: make(map[int64]domain.TelegramLoginWebAuthorization),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *TelegramLoginStore) CreateTelegramLoginClient(_ context.Context, client domain.TelegramLoginClient) (domain.TelegramLoginClient, error) {
|
||||
if err := client.Validate(); err != nil {
|
||||
return domain.TelegramLoginClient{}, err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, exists := s.clientByBot[client.BotUserID]; exists {
|
||||
return domain.TelegramLoginClient{}, domain.ErrTelegramLoginRequestConflict
|
||||
}
|
||||
if _, exists := s.clientsByID[client.ClientID]; exists {
|
||||
return domain.TelegramLoginClient{}, domain.ErrTelegramLoginRequestConflict
|
||||
}
|
||||
s.clientsByID[client.ClientID] = client.Clone()
|
||||
s.clientByBot[client.BotUserID] = client.ClientID
|
||||
return client.Clone(), nil
|
||||
}
|
||||
|
||||
func (s *TelegramLoginStore) UpsertTelegramLoginClient(_ context.Context, client domain.TelegramLoginClient) (domain.TelegramLoginClient, error) {
|
||||
if err := client.Validate(); err != nil {
|
||||
return domain.TelegramLoginClient{}, err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if existingID, exists := s.clientByBot[client.BotUserID]; exists && existingID != client.ClientID {
|
||||
delete(s.clientsByID, existingID)
|
||||
}
|
||||
if existing, exists := s.clientsByID[client.ClientID]; exists && existing.BotUserID != client.BotUserID {
|
||||
return domain.TelegramLoginClient{}, domain.ErrTelegramLoginClientInvalid
|
||||
}
|
||||
s.clientsByID[client.ClientID] = client.Clone()
|
||||
s.clientByBot[client.BotUserID] = client.ClientID
|
||||
return client.Clone(), nil
|
||||
}
|
||||
|
||||
func (s *TelegramLoginStore) GetTelegramLoginClient(_ context.Context, clientID string) (domain.TelegramLoginClient, bool, error) {
|
||||
s.mu.RLock()
|
||||
client, ok := s.clientsByID[clientID]
|
||||
s.mu.RUnlock()
|
||||
return client.Clone(), ok, nil
|
||||
}
|
||||
|
||||
func (s *TelegramLoginStore) GetTelegramLoginClientByBot(_ context.Context, botUserID int64) (domain.TelegramLoginClient, bool, error) {
|
||||
s.mu.RLock()
|
||||
clientID, ok := s.clientByBot[botUserID]
|
||||
client := s.clientsByID[clientID]
|
||||
s.mu.RUnlock()
|
||||
return client.Clone(), ok, nil
|
||||
}
|
||||
|
||||
func (s *TelegramLoginStore) RotateTelegramLoginClientSecret(_ context.Context, botUserID, expectedVersion int64, secretHash []byte, now time.Time) (domain.TelegramLoginClient, error) {
|
||||
if botUserID <= 0 || expectedVersion <= 0 || len(secretHash) != 32 {
|
||||
return domain.TelegramLoginClient{}, domain.ErrTelegramLoginClientInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
clientID, ok := s.clientByBot[botUserID]
|
||||
if !ok {
|
||||
return domain.TelegramLoginClient{}, domain.ErrTelegramLoginClientInvalid
|
||||
}
|
||||
client := s.clientsByID[clientID]
|
||||
if client.SecretVersion != expectedVersion {
|
||||
return domain.TelegramLoginClient{}, domain.ErrTelegramLoginRequestConflict
|
||||
}
|
||||
client.SecretVersion++
|
||||
client.SecretHash = append([]byte(nil), secretHash...)
|
||||
client.UpdatedAt = now
|
||||
s.clientsByID[clientID] = client
|
||||
return client.Clone(), nil
|
||||
}
|
||||
|
||||
func (s *TelegramLoginStore) SetTelegramLoginClientSigningAlgorithm(_ context.Context, botUserID int64, algorithm domain.TelegramLoginSigningAlgorithm, now time.Time) (domain.TelegramLoginClient, error) {
|
||||
if botUserID <= 0 || !algorithm.Valid() {
|
||||
return domain.TelegramLoginClient{}, domain.ErrTelegramLoginClientInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
clientID, ok := s.clientByBot[botUserID]
|
||||
if !ok {
|
||||
return domain.TelegramLoginClient{}, domain.ErrTelegramLoginClientInvalid
|
||||
}
|
||||
client := s.clientsByID[clientID]
|
||||
client.SigningAlgorithm = algorithm
|
||||
client.UpdatedAt = now
|
||||
s.clientsByID[clientID] = client
|
||||
return client.Clone(), nil
|
||||
}
|
||||
|
||||
func (s *TelegramLoginStore) SetTelegramLoginClientEnabled(_ context.Context, botUserID int64, enabled bool, now time.Time) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
clientID, ok := s.clientByBot[botUserID]
|
||||
if !ok {
|
||||
return domain.ErrTelegramLoginClientInvalid
|
||||
}
|
||||
client := s.clientsByID[clientID]
|
||||
client.Enabled = enabled
|
||||
client.UpdatedAt = now
|
||||
s.clientsByID[clientID] = client
|
||||
return nil
|
||||
}
|
||||
|
||||
func telegramLoginAllowedURLKey(botUserID int64, kind domain.TelegramLoginAllowedURLKind, value string) string {
|
||||
return strconv.FormatInt(botUserID, 10) + "\x00" + string(kind) + "\x00" + value
|
||||
}
|
||||
|
||||
func (s *TelegramLoginStore) AddTelegramLoginAllowedURL(_ context.Context, allowed domain.TelegramLoginAllowedURL) (domain.TelegramLoginAllowedURL, error) {
|
||||
if allowed.BotUserID <= 0 || allowed.NormalizedURL == "" || (allowed.Kind != domain.TelegramLoginAllowedWebOrigin && allowed.Kind != domain.TelegramLoginAllowedRedirectURI) {
|
||||
return domain.TelegramLoginAllowedURL{}, domain.ErrTelegramLoginURLInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, ok := s.clientByBot[allowed.BotUserID]; !ok {
|
||||
return domain.TelegramLoginAllowedURL{}, domain.ErrTelegramLoginClientInvalid
|
||||
}
|
||||
key := telegramLoginAllowedURLKey(allowed.BotUserID, allowed.Kind, allowed.NormalizedURL)
|
||||
if existing, ok := s.allowedURLs[key]; ok {
|
||||
return existing, nil
|
||||
}
|
||||
s.nextURLID++
|
||||
allowed.ID = s.nextURLID
|
||||
s.allowedURLs[key] = allowed
|
||||
return allowed, nil
|
||||
}
|
||||
|
||||
func (s *TelegramLoginStore) DeleteTelegramLoginAllowedURL(_ context.Context, botUserID int64, kind domain.TelegramLoginAllowedURLKind, normalizedURL string) (bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
key := telegramLoginAllowedURLKey(botUserID, kind, normalizedURL)
|
||||
if _, ok := s.allowedURLs[key]; !ok {
|
||||
return false, nil
|
||||
}
|
||||
delete(s.allowedURLs, key)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (s *TelegramLoginStore) ListTelegramLoginAllowedURLs(_ context.Context, botUserID int64) ([]domain.TelegramLoginAllowedURL, error) {
|
||||
s.mu.RLock()
|
||||
out := make([]domain.TelegramLoginAllowedURL, 0)
|
||||
for _, allowed := range s.allowedURLs {
|
||||
if allowed.BotUserID == botUserID {
|
||||
out = append(out, allowed)
|
||||
}
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *TelegramLoginStore) IsTelegramLoginURLAllowed(_ context.Context, botUserID int64, kind domain.TelegramLoginAllowedURLKind, normalizedURL string) (bool, error) {
|
||||
s.mu.RLock()
|
||||
_, ok := s.allowedURLs[telegramLoginAllowedURLKey(botUserID, kind, normalizedURL)]
|
||||
s.mu.RUnlock()
|
||||
return ok, nil
|
||||
}
|
||||
|
||||
func (s *TelegramLoginStore) UpsertTelegramLoginNativeApp(_ context.Context, app domain.TelegramLoginNativeApp) (domain.TelegramLoginNativeApp, error) {
|
||||
if err := app.Validate(); err != nil {
|
||||
return domain.TelegramLoginNativeApp{}, domain.ErrTelegramLoginClientInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, ok := s.clientByBot[app.BotUserID]; !ok {
|
||||
return domain.TelegramLoginNativeApp{}, domain.ErrTelegramLoginClientInvalid
|
||||
}
|
||||
if app.ID == 0 {
|
||||
for id, existing := range s.nativeApps {
|
||||
if existing.BotUserID == app.BotUserID && existing.Platform == app.Platform && existing.ApplicationID == app.ApplicationID && existing.VerificationID == app.VerificationID {
|
||||
app.ID, app.CreatedAt = id, existing.CreatedAt
|
||||
s.nativeApps[id] = app
|
||||
return app, nil
|
||||
}
|
||||
if existing.BotUserID == app.BotUserID && existing.CallbackURI == app.CallbackURI {
|
||||
return domain.TelegramLoginNativeApp{}, domain.ErrTelegramLoginRequestConflict
|
||||
}
|
||||
}
|
||||
count := 0
|
||||
for _, existing := range s.nativeApps {
|
||||
if existing.BotUserID == app.BotUserID {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count >= domain.MaxTelegramLoginNativeApps {
|
||||
return domain.TelegramLoginNativeApp{}, domain.ErrTelegramLoginRequestInvalid
|
||||
}
|
||||
s.nextAppID++
|
||||
app.ID = s.nextAppID
|
||||
} else if existing, ok := s.nativeApps[app.ID]; ok && existing.BotUserID != app.BotUserID {
|
||||
return domain.TelegramLoginNativeApp{}, domain.ErrTelegramLoginClientInvalid
|
||||
}
|
||||
s.nativeApps[app.ID] = app
|
||||
return app, nil
|
||||
}
|
||||
|
||||
func (s *TelegramLoginStore) DeleteTelegramLoginNativeApp(_ context.Context, botUserID, appID int64) (bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
app, ok := s.nativeApps[appID]
|
||||
if !ok || app.BotUserID != botUserID {
|
||||
return false, nil
|
||||
}
|
||||
delete(s.nativeApps, appID)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (s *TelegramLoginStore) ListTelegramLoginNativeApps(_ context.Context, botUserID int64) ([]domain.TelegramLoginNativeApp, error) {
|
||||
s.mu.RLock()
|
||||
out := make([]domain.TelegramLoginNativeApp, 0)
|
||||
for _, app := range s.nativeApps {
|
||||
if app.BotUserID == botUserID {
|
||||
out = append(out, app)
|
||||
}
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
|
||||
if len(out) > domain.MaxTelegramLoginNativeApps {
|
||||
out = out[:domain.MaxTelegramLoginNativeApps]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *TelegramLoginStore) CreateTelegramLoginRequest(_ context.Context, request domain.TelegramLoginRequest) (domain.TelegramLoginRequest, error) {
|
||||
if err := request.Validate(); err != nil {
|
||||
return domain.TelegramLoginRequest{}, err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
client, ok := s.clientsByID[request.ClientID]
|
||||
if !ok || client.BotUserID != request.BotUserID || !client.Enabled || client.SigningAlgorithm != request.SigningAlgorithm {
|
||||
return domain.TelegramLoginRequest{}, domain.ErrTelegramLoginClientDisabled
|
||||
}
|
||||
if _, exists := s.requestToken[string(request.RequestTokenHash)]; exists {
|
||||
return domain.TelegramLoginRequest{}, domain.ErrTelegramLoginRequestConflict
|
||||
}
|
||||
if _, exists := s.browserToken[string(request.BrowserTokenHash)]; exists {
|
||||
return domain.TelegramLoginRequest{}, domain.ErrTelegramLoginRequestConflict
|
||||
}
|
||||
s.nextRequestID++
|
||||
request.ID = s.nextRequestID
|
||||
s.requests[request.ID] = request.Clone()
|
||||
s.requestToken[string(request.RequestTokenHash)] = request.ID
|
||||
s.browserToken[string(request.BrowserTokenHash)] = request.ID
|
||||
return request.Clone(), nil
|
||||
}
|
||||
|
||||
func (s *TelegramLoginStore) GetTelegramLoginRequest(_ context.Context, requestID int64) (domain.TelegramLoginRequest, bool, error) {
|
||||
s.mu.RLock()
|
||||
request, ok := s.requests[requestID]
|
||||
s.mu.RUnlock()
|
||||
return request.Clone(), ok, nil
|
||||
}
|
||||
|
||||
func (s *TelegramLoginStore) GetTelegramLoginRequestByTokenHash(_ context.Context, tokenHash []byte) (domain.TelegramLoginRequest, bool, error) {
|
||||
s.mu.RLock()
|
||||
id, ok := s.requestToken[string(tokenHash)]
|
||||
request := s.requests[id]
|
||||
s.mu.RUnlock()
|
||||
return request.Clone(), ok, nil
|
||||
}
|
||||
|
||||
func (s *TelegramLoginStore) GetTelegramLoginRequestByBrowserTokenHash(_ context.Context, tokenHash []byte) (domain.TelegramLoginRequest, bool, error) {
|
||||
s.mu.RLock()
|
||||
id, ok := s.browserToken[string(tokenHash)]
|
||||
request := s.requests[id]
|
||||
s.mu.RUnlock()
|
||||
return request.Clone(), ok, nil
|
||||
}
|
||||
|
||||
func grantedTelegramLoginScopes(request domain.TelegramLoginRequest, approval domain.TelegramLoginApproval) ([]domain.TelegramLoginScope, error) {
|
||||
if approval.WriteAllowed && !request.Requests(domain.TelegramLoginScopeBotAccess) {
|
||||
return nil, domain.ErrTelegramLoginScopeInvalid
|
||||
}
|
||||
if approval.PhoneShared && !request.Requests(domain.TelegramLoginScopePhone) {
|
||||
return nil, domain.ErrTelegramLoginScopeInvalid
|
||||
}
|
||||
out := make([]domain.TelegramLoginScope, 0, len(request.Scopes))
|
||||
for _, scope := range request.Scopes {
|
||||
if scope == domain.TelegramLoginScopePhone && !approval.PhoneShared {
|
||||
continue
|
||||
}
|
||||
if scope == domain.TelegramLoginScopeBotAccess && !approval.WriteAllowed {
|
||||
continue
|
||||
}
|
||||
out = append(out, scope)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *TelegramLoginStore) ApproveTelegramLoginRequest(ctx context.Context, approval domain.TelegramLoginApproval, webAuthorizationHash int64) (domain.TelegramLoginRequest, domain.TelegramLoginWebAuthorization, error) {
|
||||
if approval.RequestID <= 0 || approval.Identity.UserID <= 0 || webAuthorizationHash == 0 || approval.ApprovedAt.IsZero() {
|
||||
return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginRequestInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
request, ok := s.requests[approval.RequestID]
|
||||
if !ok {
|
||||
return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginRequestInvalid
|
||||
}
|
||||
if request.Status != domain.TelegramLoginRequestPending {
|
||||
return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginRequestConflict
|
||||
}
|
||||
if !approval.ApprovedAt.Before(request.ExpiresAt) {
|
||||
request.Status = domain.TelegramLoginRequestExpired
|
||||
s.requests[request.ID] = request
|
||||
return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginRequestExpired
|
||||
}
|
||||
client, clientExists := s.clientsByID[request.ClientID]
|
||||
if !clientExists || !client.Enabled || client.BotUserID != request.BotUserID || client.SigningAlgorithm != request.SigningAlgorithm {
|
||||
return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginClientDisabled
|
||||
}
|
||||
if request.ResponseType == "code" {
|
||||
_, webAllowed := s.allowedURLs[telegramLoginAllowedURLKey(request.BotUserID, domain.TelegramLoginAllowedRedirectURI, request.RedirectURI)]
|
||||
if !webAllowed && !(request.Source == domain.TelegramLoginRequestNative && s.nativeCallbackAllowedLocked(request.BotUserID, request.RedirectURI)) {
|
||||
return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginRedirectNotAllowed
|
||||
}
|
||||
} else if request.ResponseType == "post_message" || request.ResponseType == "legacy_url" {
|
||||
if _, ok := s.allowedURLs[telegramLoginAllowedURLKey(request.BotUserID, domain.TelegramLoginAllowedWebOrigin, request.Origin)]; !ok {
|
||||
return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginOriginNotAllowed
|
||||
}
|
||||
} else {
|
||||
return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginRequestInvalid
|
||||
}
|
||||
if request.InAppOrigin != "" {
|
||||
if _, ok := s.allowedURLs[telegramLoginAllowedURLKey(request.BotUserID, domain.TelegramLoginAllowedWebOrigin, request.InAppOrigin)]; !ok {
|
||||
return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginOriginNotAllowed
|
||||
}
|
||||
}
|
||||
if len(request.MatchCodes) > 0 && approval.MatchCode != request.MatchCode {
|
||||
return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginMatchCodeInvalid
|
||||
}
|
||||
scopes, err := grantedTelegramLoginScopes(request, approval)
|
||||
if err != nil {
|
||||
return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, err
|
||||
}
|
||||
if _, exists := s.webAuths[webAuthorizationHash]; exists {
|
||||
return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginRequestConflict
|
||||
}
|
||||
identity, err := approval.Identity.Sanitized(request.Requests(domain.TelegramLoginScopeProfile), approval.PhoneShared)
|
||||
if err != nil {
|
||||
return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, err
|
||||
}
|
||||
activeAuthorizations := 0
|
||||
for _, authorization := range s.webAuths {
|
||||
if authorization.UserID == identity.UserID && authorization.RevokedAt.IsZero() {
|
||||
activeAuthorizations++
|
||||
}
|
||||
}
|
||||
if activeAuthorizations >= domain.MaxTelegramLoginWebAuthorizations {
|
||||
return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginAuthorizationsTooMany
|
||||
}
|
||||
if approval.WriteAllowed && s.permissions != nil {
|
||||
if _, err := s.permissions.AllowBotSendMessage(ctx, request.BotUserID, identity.UserID, true); err != nil {
|
||||
return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, err
|
||||
}
|
||||
}
|
||||
request.Status = domain.TelegramLoginRequestApproved
|
||||
request.AuthorizedUserID = identity.UserID
|
||||
request.ProfileName = identity.Name
|
||||
request.GivenName = identity.GivenName
|
||||
request.FamilyName = identity.FamilyName
|
||||
request.PreferredUsername = identity.PreferredUsername
|
||||
request.Picture = identity.Picture
|
||||
request.PhoneNumber = identity.PhoneNumber
|
||||
request.WriteAllowed = approval.WriteAllowed
|
||||
request.PhoneShared = approval.PhoneShared
|
||||
request.ApprovedAt = approval.ApprovedAt
|
||||
s.requests[request.ID] = request.Clone()
|
||||
web := domain.TelegramLoginWebAuthorization{
|
||||
Hash: webAuthorizationHash,
|
||||
RequestID: request.ID,
|
||||
UserID: identity.UserID,
|
||||
BotUserID: request.BotUserID,
|
||||
Domain: request.Domain,
|
||||
Browser: request.Browser,
|
||||
Platform: request.Platform,
|
||||
IP: request.IP,
|
||||
Region: request.Region,
|
||||
Scopes: scopes,
|
||||
PhoneShared: approval.PhoneShared,
|
||||
BotAccessGranted: approval.WriteAllowed,
|
||||
CreatedAt: approval.ApprovedAt,
|
||||
LastActiveAt: approval.ApprovedAt,
|
||||
}
|
||||
s.webAuths[web.Hash] = web.Clone()
|
||||
return request.Clone(), web.Clone(), nil
|
||||
}
|
||||
|
||||
func (s *TelegramLoginStore) DeclineTelegramLoginRequest(_ context.Context, requestID, userID int64, now time.Time) (domain.TelegramLoginRequest, error) {
|
||||
if requestID <= 0 || userID <= 0 || now.IsZero() {
|
||||
return domain.TelegramLoginRequest{}, domain.ErrTelegramLoginRequestInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
request, ok := s.requests[requestID]
|
||||
if !ok {
|
||||
return domain.TelegramLoginRequest{}, domain.ErrTelegramLoginRequestInvalid
|
||||
}
|
||||
if request.Status != domain.TelegramLoginRequestPending {
|
||||
return domain.TelegramLoginRequest{}, domain.ErrTelegramLoginRequestConflict
|
||||
}
|
||||
if !now.Before(request.ExpiresAt) {
|
||||
request.Status = domain.TelegramLoginRequestExpired
|
||||
s.requests[request.ID] = request
|
||||
return domain.TelegramLoginRequest{}, domain.ErrTelegramLoginRequestExpired
|
||||
}
|
||||
request.Status = domain.TelegramLoginRequestDeclined
|
||||
request.DeclinedAt = now
|
||||
s.requests[request.ID] = request.Clone()
|
||||
return request.Clone(), nil
|
||||
}
|
||||
|
||||
func (s *TelegramLoginStore) PutTelegramLoginAuthorizationCode(_ context.Context, code domain.TelegramLoginAuthorizationCode) (domain.TelegramLoginAuthorizationCode, error) {
|
||||
if code.RequestID <= 0 || len(code.CodeHash) != 32 || len(code.SealedCode) < 32 || len(code.SealNonce) < 12 || code.SealKeyID == "" || code.IssuedAt.IsZero() || !code.ExpiresAt.After(code.IssuedAt) {
|
||||
return domain.TelegramLoginAuthorizationCode{}, domain.ErrTelegramLoginCodeInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
request, ok := s.requests[code.RequestID]
|
||||
if !ok || request.Status != domain.TelegramLoginRequestApproved {
|
||||
return domain.TelegramLoginAuthorizationCode{}, domain.ErrTelegramLoginRequestConflict
|
||||
}
|
||||
client, clientExists := s.clientsByID[request.ClientID]
|
||||
if !clientExists || !client.Enabled || client.BotUserID != request.BotUserID || client.SigningAlgorithm != request.SigningAlgorithm {
|
||||
return domain.TelegramLoginAuthorizationCode{}, domain.ErrTelegramLoginClientDisabled
|
||||
}
|
||||
switch request.ResponseType {
|
||||
case "code":
|
||||
_, allowed := s.allowedURLs[telegramLoginAllowedURLKey(request.BotUserID, domain.TelegramLoginAllowedRedirectURI, request.RedirectURI)]
|
||||
if !allowed && !(request.Source == domain.TelegramLoginRequestNative && s.nativeCallbackAllowedLocked(request.BotUserID, request.RedirectURI)) {
|
||||
return domain.TelegramLoginAuthorizationCode{}, domain.ErrTelegramLoginRedirectNotAllowed
|
||||
}
|
||||
case "post_message":
|
||||
if _, allowed := s.allowedURLs[telegramLoginAllowedURLKey(request.BotUserID, domain.TelegramLoginAllowedWebOrigin, request.Origin)]; !allowed {
|
||||
return domain.TelegramLoginAuthorizationCode{}, domain.ErrTelegramLoginOriginNotAllowed
|
||||
}
|
||||
default:
|
||||
return domain.TelegramLoginAuthorizationCode{}, domain.ErrTelegramLoginRequestConflict
|
||||
}
|
||||
web, active := s.webAuthByRequestLocked(request.ID)
|
||||
if !active || !web.RevokedAt.IsZero() {
|
||||
return domain.TelegramLoginAuthorizationCode{}, domain.ErrTelegramLoginRequestConflict
|
||||
}
|
||||
if id, exists := s.codeByRequest[code.RequestID]; exists {
|
||||
return s.codes[id].Clone(), nil
|
||||
}
|
||||
if _, exists := s.codeByHash[string(code.CodeHash)]; exists {
|
||||
return domain.TelegramLoginAuthorizationCode{}, domain.ErrTelegramLoginRequestConflict
|
||||
}
|
||||
s.nextCodeID++
|
||||
code.ID = s.nextCodeID
|
||||
s.codes[code.ID] = code.Clone()
|
||||
s.codeByHash[string(code.CodeHash)] = code.ID
|
||||
s.codeByRequest[code.RequestID] = code.ID
|
||||
return code.Clone(), nil
|
||||
}
|
||||
|
||||
func (s *TelegramLoginStore) GetTelegramLoginAuthorizationCodeByRequest(_ context.Context, requestID int64) (domain.TelegramLoginAuthorizationCode, bool, error) {
|
||||
s.mu.RLock()
|
||||
id, ok := s.codeByRequest[requestID]
|
||||
code := s.codes[id]
|
||||
s.mu.RUnlock()
|
||||
return code.Clone(), ok, nil
|
||||
}
|
||||
|
||||
func (s *TelegramLoginStore) GetTelegramLoginAuthorizationCodeByHash(_ context.Context, codeHash []byte) (domain.TelegramLoginAuthorizationCode, bool, error) {
|
||||
s.mu.RLock()
|
||||
id, ok := s.codeByHash[string(codeHash)]
|
||||
code := s.codes[id]
|
||||
s.mu.RUnlock()
|
||||
return code.Clone(), ok, nil
|
||||
}
|
||||
|
||||
func (s *TelegramLoginStore) ConsumeTelegramLoginAuthorizationCode(_ context.Context, exchange domain.TelegramLoginCodeExchange) (domain.TelegramLoginAuthorizationCode, domain.TelegramLoginRequest, domain.TelegramLoginWebAuthorization, error) {
|
||||
if len(exchange.CodeHash) != 32 || exchange.ClientID == "" || exchange.ClientSecretVersion <= 0 || exchange.RedirectURI == "" || exchange.CodeChallenge == "" || exchange.Now.IsZero() {
|
||||
return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
id, ok := s.codeByHash[string(exchange.CodeHash)]
|
||||
if !ok {
|
||||
return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeInvalid
|
||||
}
|
||||
code := s.codes[id]
|
||||
if !code.ConsumedAt.IsZero() {
|
||||
return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeConsumed
|
||||
}
|
||||
if !exchange.Now.Before(code.ExpiresAt) {
|
||||
return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeInvalid
|
||||
}
|
||||
request := s.requests[code.RequestID]
|
||||
client, clientExists := s.clientsByID[exchange.ClientID]
|
||||
if !clientExists || !client.Enabled || client.SecretVersion != exchange.ClientSecretVersion || request.ResponseType != "code" || request.ClientID != exchange.ClientID || request.RedirectURI != exchange.RedirectURI || request.CodeChallenge != exchange.CodeChallenge {
|
||||
return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeInvalid
|
||||
}
|
||||
_, webAllowed := s.allowedURLs[telegramLoginAllowedURLKey(request.BotUserID, domain.TelegramLoginAllowedRedirectURI, request.RedirectURI)]
|
||||
if !webAllowed && !(request.Source == domain.TelegramLoginRequestNative && s.nativeCallbackAllowedLocked(request.BotUserID, request.RedirectURI)) {
|
||||
return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeInvalid
|
||||
}
|
||||
web, exists := s.webAuthByRequestLocked(code.RequestID)
|
||||
if request.Status != domain.TelegramLoginRequestApproved || !exists || !web.RevokedAt.IsZero() {
|
||||
return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeInvalid
|
||||
}
|
||||
code.ConsumedAt = exchange.Now
|
||||
web.LastActiveAt = exchange.Now
|
||||
s.codes[id] = code.Clone()
|
||||
s.webAuths[web.Hash] = web.Clone()
|
||||
return code.Clone(), request.Clone(), web.Clone(), nil
|
||||
}
|
||||
|
||||
func (s *TelegramLoginStore) ConsumeTelegramLoginDirectToken(_ context.Context, tokenHash []byte, origin string, now time.Time) (domain.TelegramLoginAuthorizationCode, domain.TelegramLoginRequest, domain.TelegramLoginWebAuthorization, error) {
|
||||
if len(tokenHash) != 32 || origin == "" || now.IsZero() {
|
||||
return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
id, ok := s.codeByHash[string(tokenHash)]
|
||||
if !ok {
|
||||
return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeInvalid
|
||||
}
|
||||
code := s.codes[id]
|
||||
if !code.ConsumedAt.IsZero() {
|
||||
return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeConsumed
|
||||
}
|
||||
if !now.Before(code.ExpiresAt) {
|
||||
return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeInvalid
|
||||
}
|
||||
request := s.requests[code.RequestID]
|
||||
client, clientExists := s.clientsByID[request.ClientID]
|
||||
if !clientExists || !client.Enabled || client.BotUserID != request.BotUserID ||
|
||||
request.Status != domain.TelegramLoginRequestApproved || request.Source != domain.TelegramLoginRequestMiniApp ||
|
||||
request.ResponseType != "post_message" || request.Origin != origin || request.InAppOrigin != origin {
|
||||
return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeInvalid
|
||||
}
|
||||
if _, allowed := s.allowedURLs[telegramLoginAllowedURLKey(request.BotUserID, domain.TelegramLoginAllowedWebOrigin, origin)]; !allowed {
|
||||
return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeInvalid
|
||||
}
|
||||
web, exists := s.webAuthByRequestLocked(code.RequestID)
|
||||
if !exists || !web.RevokedAt.IsZero() {
|
||||
return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeInvalid
|
||||
}
|
||||
code.ConsumedAt = now
|
||||
web.LastActiveAt = now
|
||||
s.codes[id] = code.Clone()
|
||||
s.webAuths[web.Hash] = web.Clone()
|
||||
return code.Clone(), request.Clone(), web.Clone(), nil
|
||||
}
|
||||
|
||||
func (s *TelegramLoginStore) webAuthByRequestLocked(requestID int64) (domain.TelegramLoginWebAuthorization, bool) {
|
||||
for _, web := range s.webAuths {
|
||||
if web.RequestID == requestID {
|
||||
return web, true
|
||||
}
|
||||
}
|
||||
return domain.TelegramLoginWebAuthorization{}, false
|
||||
}
|
||||
|
||||
func (s *TelegramLoginStore) nativeCallbackAllowedLocked(botUserID int64, callbackURI string) bool {
|
||||
for _, app := range s.nativeApps {
|
||||
if app.BotUserID == botUserID && app.Enabled && app.CallbackURI == callbackURI {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *TelegramLoginStore) ListTelegramLoginWebAuthorizations(_ context.Context, userID int64) ([]domain.TelegramLoginWebAuthorization, error) {
|
||||
s.mu.RLock()
|
||||
out := make([]domain.TelegramLoginWebAuthorization, 0, min(len(s.webAuths), domain.MaxTelegramLoginWebAuthorizations))
|
||||
for _, web := range s.webAuths {
|
||||
if web.UserID == userID && web.RevokedAt.IsZero() {
|
||||
out = append(out, web.Clone())
|
||||
}
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].LastActiveAt.Equal(out[j].LastActiveAt) {
|
||||
return out[i].Hash > out[j].Hash
|
||||
}
|
||||
return out[i].LastActiveAt.After(out[j].LastActiveAt)
|
||||
})
|
||||
if len(out) > domain.MaxTelegramLoginWebAuthorizations {
|
||||
out = out[:domain.MaxTelegramLoginWebAuthorizations]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *TelegramLoginStore) RevokeTelegramLoginWebAuthorization(_ context.Context, userID, hash int64, now time.Time) (bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
web, ok := s.webAuths[hash]
|
||||
if !ok || web.UserID != userID || !web.RevokedAt.IsZero() {
|
||||
return false, nil
|
||||
}
|
||||
web.RevokedAt = now
|
||||
s.webAuths[hash] = web
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (s *TelegramLoginStore) RevokeAllTelegramLoginWebAuthorizations(_ context.Context, userID int64, now time.Time) (int64, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
var count int64
|
||||
for hash, web := range s.webAuths {
|
||||
if web.UserID == userID && web.RevokedAt.IsZero() {
|
||||
web.RevokedAt = now
|
||||
s.webAuths[hash] = web
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (s *TelegramLoginStore) DeleteExpiredTelegramLoginArtifacts(_ context.Context, before time.Time, limit int) (int64, error) {
|
||||
if limit <= 0 || limit > 1000 {
|
||||
return 0, domain.ErrTelegramLoginRequestInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
var deleted int64
|
||||
for id, code := range s.codes {
|
||||
if deleted >= int64(limit) {
|
||||
break
|
||||
}
|
||||
if code.ExpiresAt.Before(before) || (!code.ConsumedAt.IsZero() && code.ConsumedAt.Before(before)) {
|
||||
delete(s.codes, id)
|
||||
delete(s.codeByHash, string(code.CodeHash))
|
||||
delete(s.codeByRequest, code.RequestID)
|
||||
deleted++
|
||||
}
|
||||
}
|
||||
for id, request := range s.requests {
|
||||
if deleted >= int64(limit) {
|
||||
break
|
||||
}
|
||||
deleteRequest := (request.Status == domain.TelegramLoginRequestPending || request.Status == domain.TelegramLoginRequestDeclined || request.Status == domain.TelegramLoginRequestExpired) && request.ExpiresAt.Before(before)
|
||||
var revokedWebHash int64
|
||||
if request.Status == domain.TelegramLoginRequestApproved && !request.ApprovedAt.IsZero() && request.ApprovedAt.Before(before) {
|
||||
// Approved requests remain the immutable claim snapshot behind an active
|
||||
// web authorization. They may only be collected after the grant itself
|
||||
// was revoked and every exchange code has left the retention window.
|
||||
for hash, web := range s.webAuths {
|
||||
if web.RequestID == id && !web.RevokedAt.IsZero() && web.RevokedAt.Before(before) {
|
||||
deleteRequest = true
|
||||
revokedWebHash = hash
|
||||
break
|
||||
}
|
||||
}
|
||||
if _, hasCode := s.codeByRequest[id]; hasCode {
|
||||
deleteRequest = false
|
||||
}
|
||||
}
|
||||
if !deleteRequest {
|
||||
continue
|
||||
}
|
||||
delete(s.requests, id)
|
||||
delete(s.requestToken, string(request.RequestTokenHash))
|
||||
delete(s.browserToken, string(request.BrowserTokenHash))
|
||||
if revokedWebHash != 0 {
|
||||
delete(s.webAuths, revokedWebHash)
|
||||
}
|
||||
deleted++
|
||||
}
|
||||
return deleted, nil
|
||||
}
|
||||
320
internal/store/memory/telegram_login_test.go
Normal file
320
internal/store/memory/telegram_login_test.go
Normal file
|
|
@ -0,0 +1,320 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
type telegramLoginPermissionRecorder struct {
|
||||
mu sync.Mutex
|
||||
grants map[[2]int64]int
|
||||
}
|
||||
|
||||
func (r *telegramLoginPermissionRecorder) AllowBotSendMessage(_ context.Context, botUserID, userID int64, _ bool) (bool, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.grants == nil {
|
||||
r.grants = make(map[[2]int64]int)
|
||||
}
|
||||
key := [2]int64{botUserID, userID}
|
||||
created := r.grants[key] == 0
|
||||
r.grants[key]++
|
||||
return created, nil
|
||||
}
|
||||
|
||||
func telegramLoginTestHash(value string) []byte {
|
||||
sum := sha256.Sum256([]byte(value))
|
||||
return sum[:]
|
||||
}
|
||||
|
||||
func seedTelegramLoginRequest(t *testing.T, s *TelegramLoginStore, now time.Time) domain.TelegramLoginRequest {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
client := domain.TelegramLoginClient{
|
||||
BotUserID: 9001,
|
||||
ClientID: "9001",
|
||||
SecretHash: telegramLoginTestHash("client-secret"),
|
||||
SecretVersion: 1,
|
||||
SigningAlgorithm: domain.TelegramLoginSigningRS256,
|
||||
Enabled: true,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if _, err := s.UpsertTelegramLoginClient(ctx, client); err != nil {
|
||||
t.Fatalf("UpsertTelegramLoginClient: %v", err)
|
||||
}
|
||||
if _, err := s.AddTelegramLoginAllowedURL(ctx, domain.TelegramLoginAllowedURL{
|
||||
BotUserID: client.BotUserID, Kind: domain.TelegramLoginAllowedRedirectURI,
|
||||
NormalizedURL: "https://rp.example/callback", CreatedAt: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("AddTelegramLoginAllowedURL: %v", err)
|
||||
}
|
||||
request := domain.TelegramLoginRequest{
|
||||
RequestTokenHash: telegramLoginTestHash("request-token"),
|
||||
BrowserTokenHash: telegramLoginTestHash("browser-token"),
|
||||
BotUserID: client.BotUserID,
|
||||
ClientID: client.ClientID,
|
||||
SigningAlgorithm: client.SigningAlgorithm,
|
||||
Source: domain.TelegramLoginRequestWeb,
|
||||
ResponseType: "code",
|
||||
RedirectURI: "https://rp.example/callback",
|
||||
Origin: "https://rp.example",
|
||||
Domain: "rp.example",
|
||||
Scopes: []domain.TelegramLoginScope{domain.TelegramLoginScopeOpenID, domain.TelegramLoginScopeProfile, domain.TelegramLoginScopePhone, domain.TelegramLoginScopeBotAccess},
|
||||
State: "state",
|
||||
Nonce: "nonce",
|
||||
CodeChallenge: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
CodeChallengeMethod: "S256",
|
||||
Browser: "Firefox",
|
||||
Platform: "Windows",
|
||||
IP: "192.0.2.10",
|
||||
Region: "Test Region",
|
||||
MatchCodes: []string{"🟢", "🔵", "🟠"},
|
||||
MatchCode: "🔵",
|
||||
MatchCodesFirst: true,
|
||||
Status: domain.TelegramLoginRequestPending,
|
||||
CreatedAt: now,
|
||||
ExpiresAt: now.Add(5 * time.Minute),
|
||||
}
|
||||
created, err := s.CreateTelegramLoginRequest(ctx, request)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateTelegramLoginRequest: %v", err)
|
||||
}
|
||||
return created
|
||||
}
|
||||
|
||||
func approveTelegramLoginRequest(t *testing.T, s *TelegramLoginStore, request domain.TelegramLoginRequest, now time.Time) (domain.TelegramLoginRequest, domain.TelegramLoginWebAuthorization) {
|
||||
t.Helper()
|
||||
approved, web, err := s.ApproveTelegramLoginRequest(context.Background(), domain.TelegramLoginApproval{
|
||||
RequestID: request.ID,
|
||||
Identity: domain.TelegramLoginIdentitySnapshot{
|
||||
UserID: 42, Name: "Alice Example", GivenName: "Alice", FamilyName: "Example",
|
||||
PreferredUsername: "alice", Picture: "https://oauth.example/userpic/42",
|
||||
},
|
||||
WriteAllowed: true,
|
||||
PhoneShared: false,
|
||||
MatchCode: request.MatchCode,
|
||||
ApprovedAt: now,
|
||||
}, 7000+request.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ApproveTelegramLoginRequest: %v", err)
|
||||
}
|
||||
return approved, web
|
||||
}
|
||||
|
||||
func TestTelegramLoginApproveIsAtomicAndShrinksConsent(t *testing.T) {
|
||||
now := time.Unix(1_780_000_000, 0)
|
||||
permissions := &telegramLoginPermissionRecorder{}
|
||||
s := NewTelegramLoginStore(permissions)
|
||||
request := seedTelegramLoginRequest(t, s, now)
|
||||
approved, web := approveTelegramLoginRequest(t, s, request, now.Add(time.Second))
|
||||
if approved.Status != domain.TelegramLoginRequestApproved || approved.AuthorizedUserID != 42 {
|
||||
t.Fatalf("approved request = %#v", approved)
|
||||
}
|
||||
if web.PhoneShared || web.BotAccessGranted != true {
|
||||
t.Fatalf("web consent = %#v", web)
|
||||
}
|
||||
if len(web.Scopes) != 3 || web.Scopes[0] != domain.TelegramLoginScopeOpenID || web.Scopes[1] != domain.TelegramLoginScopeProfile || web.Scopes[2] != domain.TelegramLoginScopeBotAccess {
|
||||
t.Fatalf("granted scopes = %#v", web.Scopes)
|
||||
}
|
||||
permissions.mu.Lock()
|
||||
grants := permissions.grants[[2]int64{9001, 42}]
|
||||
permissions.mu.Unlock()
|
||||
if grants != 1 {
|
||||
t.Fatalf("bot permission grants = %d, want 1", grants)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramLoginAcceptDeclineRaceHasOneTerminalState(t *testing.T) {
|
||||
now := time.Unix(1_780_000_000, 0)
|
||||
s := NewTelegramLoginStore(nil)
|
||||
request := seedTelegramLoginRequest(t, s, now)
|
||||
start := make(chan struct{})
|
||||
errs := make(chan error, 2)
|
||||
go func() {
|
||||
<-start
|
||||
_, _, err := s.ApproveTelegramLoginRequest(context.Background(), domain.TelegramLoginApproval{
|
||||
RequestID: request.ID,
|
||||
Identity: domain.TelegramLoginIdentitySnapshot{UserID: 42, Name: "Alice", GivenName: "Alice"},
|
||||
MatchCode: request.MatchCode, ApprovedAt: now.Add(time.Second),
|
||||
}, 7001)
|
||||
errs <- err
|
||||
}()
|
||||
go func() {
|
||||
<-start
|
||||
_, err := s.DeclineTelegramLoginRequest(context.Background(), request.ID, 42, now.Add(time.Second))
|
||||
errs <- err
|
||||
}()
|
||||
close(start)
|
||||
var success, conflict int
|
||||
for range 2 {
|
||||
err := <-errs
|
||||
switch {
|
||||
case err == nil:
|
||||
success++
|
||||
case errors.Is(err, domain.ErrTelegramLoginRequestConflict):
|
||||
conflict++
|
||||
default:
|
||||
t.Fatalf("unexpected race error: %v", err)
|
||||
}
|
||||
}
|
||||
if success != 1 || conflict != 1 {
|
||||
t.Fatalf("success=%d conflict=%d, want 1/1", success, conflict)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramLoginAuthorizationCodeSingleConsumeAndRevocation(t *testing.T) {
|
||||
now := time.Unix(1_780_000_000, 0)
|
||||
s := NewTelegramLoginStore(nil)
|
||||
request := seedTelegramLoginRequest(t, s, now)
|
||||
approveTelegramLoginRequest(t, s, request, now.Add(time.Second))
|
||||
code := domain.TelegramLoginAuthorizationCode{
|
||||
RequestID: request.ID,
|
||||
CodeHash: telegramLoginTestHash("authorization-code"),
|
||||
SealedCode: append(make([]byte, 32), 1),
|
||||
SealNonce: make([]byte, 12),
|
||||
SealKeyID: "test-key",
|
||||
IssuedAt: now.Add(2 * time.Second),
|
||||
ExpiresAt: now.Add(time.Minute),
|
||||
}
|
||||
if _, err := s.PutTelegramLoginAuthorizationCode(context.Background(), code); err != nil {
|
||||
t.Fatalf("PutTelegramLoginAuthorizationCode: %v", err)
|
||||
}
|
||||
|
||||
start := make(chan struct{})
|
||||
errs := make(chan error, 8)
|
||||
for range 8 {
|
||||
go func() {
|
||||
<-start
|
||||
_, _, _, err := s.ConsumeTelegramLoginAuthorizationCode(context.Background(), domain.TelegramLoginCodeExchange{
|
||||
CodeHash: code.CodeHash, ClientID: request.ClientID, ClientSecretVersion: 1,
|
||||
RedirectURI: request.RedirectURI, CodeChallenge: request.CodeChallenge, Now: now.Add(3 * time.Second),
|
||||
})
|
||||
errs <- err
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
var success, consumed int
|
||||
for range 8 {
|
||||
err := <-errs
|
||||
switch {
|
||||
case err == nil:
|
||||
success++
|
||||
case errors.Is(err, domain.ErrTelegramLoginCodeConsumed):
|
||||
consumed++
|
||||
default:
|
||||
t.Fatalf("unexpected consume error: %v", err)
|
||||
}
|
||||
}
|
||||
if success != 1 || consumed != 7 {
|
||||
t.Fatalf("success=%d consumed=%d, want 1/7", success, consumed)
|
||||
}
|
||||
|
||||
request2 := request.Clone()
|
||||
request2.ID = 0
|
||||
request2.RequestTokenHash = telegramLoginTestHash("request-token-2")
|
||||
request2.BrowserTokenHash = telegramLoginTestHash("browser-token-2")
|
||||
request2, err := s.CreateTelegramLoginRequest(context.Background(), request2)
|
||||
if err != nil {
|
||||
t.Fatalf("Create second request: %v", err)
|
||||
}
|
||||
_, web2 := approveTelegramLoginRequest(t, s, request2, now.Add(4*time.Second))
|
||||
code2 := code.Clone()
|
||||
code2.ID = 0
|
||||
code2.RequestID = request2.ID
|
||||
code2.CodeHash = telegramLoginTestHash("authorization-code-2")
|
||||
if _, err := s.PutTelegramLoginAuthorizationCode(context.Background(), code2); err != nil {
|
||||
t.Fatalf("Put second code: %v", err)
|
||||
}
|
||||
if revoked, err := s.RevokeTelegramLoginWebAuthorization(context.Background(), web2.UserID, web2.Hash, now.Add(5*time.Second)); err != nil || !revoked {
|
||||
t.Fatalf("RevokeTelegramLoginWebAuthorization = %v,%v", revoked, err)
|
||||
}
|
||||
if _, _, _, err := s.ConsumeTelegramLoginAuthorizationCode(context.Background(), domain.TelegramLoginCodeExchange{
|
||||
CodeHash: code2.CodeHash, ClientID: request2.ClientID, ClientSecretVersion: 1,
|
||||
RedirectURI: request2.RedirectURI, CodeChallenge: request2.CodeChallenge, Now: now.Add(6 * time.Second),
|
||||
}); !errors.Is(err, domain.ErrTelegramLoginCodeInvalid) {
|
||||
t.Fatalf("consume after revoke error = %v, want code invalid", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramLoginRetentionPreservesActiveAndReferencedApprovals(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := time.Unix(1_780_000_000, 0)
|
||||
before := now.Add(24 * time.Hour)
|
||||
s := NewTelegramLoginStore(nil)
|
||||
|
||||
active := seedTelegramLoginRequest(t, s, now)
|
||||
_, activeWeb := approveTelegramLoginRequest(t, s, active, now.Add(time.Second))
|
||||
|
||||
revoked := active.Clone()
|
||||
revoked.ID = 0
|
||||
revoked.RequestTokenHash = telegramLoginTestHash("retention-revoked-request")
|
||||
revoked.BrowserTokenHash = telegramLoginTestHash("retention-revoked-browser")
|
||||
revoked.Status = domain.TelegramLoginRequestPending
|
||||
revoked.AuthorizedUserID = 0
|
||||
revoked.ProfileName, revoked.GivenName, revoked.FamilyName = "", "", ""
|
||||
revoked.PreferredUsername, revoked.Picture, revoked.PhoneNumber = "", "", ""
|
||||
revoked.WriteAllowed, revoked.PhoneShared = false, false
|
||||
revoked.ApprovedAt = time.Time{}
|
||||
revoked, err := s.CreateTelegramLoginRequest(ctx, revoked)
|
||||
if err != nil {
|
||||
t.Fatalf("create revoked request: %v", err)
|
||||
}
|
||||
_, revokedWeb := approveTelegramLoginRequest(t, s, revoked, now.Add(2*time.Second))
|
||||
if ok, err := s.RevokeTelegramLoginWebAuthorization(ctx, revokedWeb.UserID, revokedWeb.Hash, now.Add(3*time.Second)); err != nil || !ok {
|
||||
t.Fatalf("revoke old authorization = %v,%v", ok, err)
|
||||
}
|
||||
|
||||
referenced := revoked.Clone()
|
||||
referenced.ID = 0
|
||||
referenced.RequestTokenHash = telegramLoginTestHash("retention-referenced-request")
|
||||
referenced.BrowserTokenHash = telegramLoginTestHash("retention-referenced-browser")
|
||||
referenced.Status = domain.TelegramLoginRequestPending
|
||||
referenced.AuthorizedUserID = 0
|
||||
referenced.ProfileName, referenced.GivenName, referenced.FamilyName = "", "", ""
|
||||
referenced.PreferredUsername, referenced.Picture, referenced.PhoneNumber = "", "", ""
|
||||
referenced.WriteAllowed, referenced.PhoneShared = false, false
|
||||
referenced.ApprovedAt = time.Time{}
|
||||
referenced, err = s.CreateTelegramLoginRequest(ctx, referenced)
|
||||
if err != nil {
|
||||
t.Fatalf("create referenced request: %v", err)
|
||||
}
|
||||
_, referencedWeb := approveTelegramLoginRequest(t, s, referenced, now.Add(4*time.Second))
|
||||
if _, err := s.PutTelegramLoginAuthorizationCode(ctx, domain.TelegramLoginAuthorizationCode{
|
||||
RequestID: referenced.ID, CodeHash: telegramLoginTestHash("retention-live-code"),
|
||||
SealedCode: append(make([]byte, 32), 1), SealNonce: make([]byte, 12), SealKeyID: "test-key",
|
||||
IssuedAt: before.Add(time.Hour), ExpiresAt: before.Add(2 * time.Hour),
|
||||
}); err != nil {
|
||||
t.Fatalf("put retained code: %v", err)
|
||||
}
|
||||
if ok, err := s.RevokeTelegramLoginWebAuthorization(ctx, referencedWeb.UserID, referencedWeb.Hash, now.Add(5*time.Second)); err != nil || !ok {
|
||||
t.Fatalf("revoke referenced authorization = %v,%v", ok, err)
|
||||
}
|
||||
|
||||
deleted, err := s.DeleteExpiredTelegramLoginArtifacts(ctx, before, 100)
|
||||
if err != nil {
|
||||
t.Fatalf("delete expired artifacts: %v", err)
|
||||
}
|
||||
if deleted != 1 {
|
||||
t.Fatalf("deleted = %d, want revoked request only", deleted)
|
||||
}
|
||||
if _, found, _ := s.GetTelegramLoginRequest(ctx, active.ID); !found {
|
||||
t.Fatal("active authorization request was deleted")
|
||||
}
|
||||
if _, found, _ := s.GetTelegramLoginRequest(ctx, referenced.ID); !found {
|
||||
t.Fatal("request with retained code was deleted")
|
||||
}
|
||||
if _, found, _ := s.GetTelegramLoginRequest(ctx, revoked.ID); found {
|
||||
t.Fatal("old revoked authorization request was retained")
|
||||
}
|
||||
listed, err := s.ListTelegramLoginWebAuthorizations(ctx, activeWeb.UserID)
|
||||
if err != nil || len(listed) != 1 || listed[0].Hash != activeWeb.Hash {
|
||||
t.Fatalf("active authorizations after retention = %#v, %v", listed, err)
|
||||
}
|
||||
}
|
||||
|
|
@ -14,7 +14,7 @@ func TestStarGiftLifecycleMigrationsApply(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatalf("migrate star gift lifecycle schema: %v", err)
|
||||
}
|
||||
if status.Dirty || status.Empty || status.Version != 124 {
|
||||
t.Fatalf("migration status = %+v, want clean version 124", status)
|
||||
if status.Dirty || status.Empty || status.Version != 125 {
|
||||
t.Fatalf("migration status = %+v, want clean version 125", status)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
1106
internal/store/postgres/telegram_login.go
Normal file
1106
internal/store/postgres/telegram_login.go
Normal file
File diff suppressed because it is too large
Load diff
434
internal/store/postgres/telegram_login_integration_test.go
Normal file
434
internal/store/postgres/telegram_login_integration_test.go
Normal file
|
|
@ -0,0 +1,434 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func telegramLoginPGHash(value string) []byte {
|
||||
sum := sha256.Sum256([]byte(value))
|
||||
return sum[:]
|
||||
}
|
||||
|
||||
func TestTelegramLoginStorePostgresAtomicStateAndCodeConsumption(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
now := time.Now().UTC().Truncate(time.Microsecond)
|
||||
suffix := now.UnixNano() % 1_000_000_000
|
||||
|
||||
users := NewUserStore(pool)
|
||||
bots := NewBotStore(pool)
|
||||
owner, err := users.Create(ctx, domain.User{
|
||||
AccessHash: suffix + 101,
|
||||
Phone: fmt.Sprintf("1777%09d", suffix),
|
||||
FirstName: "OIDC Owner",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create oidc owner: %v", err)
|
||||
}
|
||||
bot, _, err := bots.CreateBotAccount(ctx, domain.User{
|
||||
AccessHash: suffix + 102,
|
||||
FirstName: "OIDC Test Bot",
|
||||
Username: fmt.Sprintf("oidc_%09d_bot", suffix),
|
||||
}, domain.BotProfile{OwnerUserID: owner.ID, TokenSecret: "bot-secret"})
|
||||
if err != nil {
|
||||
t.Fatalf("create oidc bot: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id IN ($1,$2)", owner.ID, bot.ID)
|
||||
})
|
||||
|
||||
store := NewTelegramLoginStore(pool)
|
||||
client, err := store.UpsertTelegramLoginClient(ctx, domain.TelegramLoginClient{
|
||||
BotUserID: bot.ID,
|
||||
ClientID: fmt.Sprintf("%d", bot.ID),
|
||||
SecretHash: telegramLoginPGHash("client-secret"),
|
||||
SecretVersion: 1,
|
||||
SigningAlgorithm: domain.TelegramLoginSigningRS256,
|
||||
Enabled: true,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("upsert oidc client: %v", err)
|
||||
}
|
||||
redirectURI := fmt.Sprintf("https://rp-%d.example/callback", suffix)
|
||||
origin := fmt.Sprintf("https://rp-%d.example", suffix)
|
||||
if _, err := store.AddTelegramLoginAllowedURL(ctx, domain.TelegramLoginAllowedURL{
|
||||
BotUserID: client.BotUserID, Kind: domain.TelegramLoginAllowedRedirectURI,
|
||||
NormalizedURL: redirectURI, CreatedAt: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("add redirect: %v", err)
|
||||
}
|
||||
if _, err := store.AddTelegramLoginAllowedURL(ctx, domain.TelegramLoginAllowedURL{
|
||||
BotUserID: client.BotUserID, Kind: domain.TelegramLoginAllowedWebOrigin,
|
||||
NormalizedURL: origin, CreatedAt: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("add web origin: %v", err)
|
||||
}
|
||||
|
||||
newRequest := func(label string) domain.TelegramLoginRequest {
|
||||
request, err := store.CreateTelegramLoginRequest(ctx, domain.TelegramLoginRequest{
|
||||
RequestTokenHash: telegramLoginPGHash("request-" + label),
|
||||
BrowserTokenHash: telegramLoginPGHash("browser-" + label),
|
||||
BotUserID: bot.ID,
|
||||
ClientID: client.ClientID,
|
||||
SigningAlgorithm: client.SigningAlgorithm,
|
||||
Source: domain.TelegramLoginRequestWeb,
|
||||
ResponseType: "code",
|
||||
RedirectURI: redirectURI,
|
||||
Origin: origin,
|
||||
Domain: fmt.Sprintf("rp-%d.example", suffix),
|
||||
Scopes: []domain.TelegramLoginScope{domain.TelegramLoginScopeOpenID, domain.TelegramLoginScopeProfile, domain.TelegramLoginScopeBotAccess},
|
||||
State: "state",
|
||||
Nonce: "nonce",
|
||||
CodeChallenge: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
CodeChallengeMethod: "S256",
|
||||
Browser: "Firefox",
|
||||
Platform: "Windows",
|
||||
IP: "192.0.2.10",
|
||||
Region: "Test Region",
|
||||
MatchCodes: []string{"🟢", "🔵", "🟠"},
|
||||
MatchCode: "🔵",
|
||||
MatchCodesFirst: true,
|
||||
Status: domain.TelegramLoginRequestPending,
|
||||
CreatedAt: now,
|
||||
ExpiresAt: now.Add(5 * time.Minute),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create request %s: %v", label, err)
|
||||
}
|
||||
return request
|
||||
}
|
||||
|
||||
request := newRequest(fmt.Sprintf("race-%d", suffix))
|
||||
start := make(chan struct{})
|
||||
errs := make(chan error, 2)
|
||||
go func() {
|
||||
<-start
|
||||
_, _, err := store.ApproveTelegramLoginRequest(ctx, domain.TelegramLoginApproval{
|
||||
RequestID: request.ID,
|
||||
Identity: domain.TelegramLoginIdentitySnapshot{UserID: owner.ID, Name: owner.FirstName, GivenName: owner.FirstName},
|
||||
WriteAllowed: true,
|
||||
MatchCode: request.MatchCode, ApprovedAt: now.Add(time.Second),
|
||||
}, suffix+10_000)
|
||||
errs <- err
|
||||
}()
|
||||
go func() {
|
||||
<-start
|
||||
_, err := store.DeclineTelegramLoginRequest(ctx, request.ID, owner.ID, now.Add(time.Second))
|
||||
errs <- err
|
||||
}()
|
||||
close(start)
|
||||
var success, conflict int
|
||||
for range 2 {
|
||||
err := <-errs
|
||||
switch {
|
||||
case err == nil:
|
||||
success++
|
||||
case errors.Is(err, domain.ErrTelegramLoginRequestConflict):
|
||||
conflict++
|
||||
default:
|
||||
t.Fatalf("accept/decline race error: %v", err)
|
||||
}
|
||||
}
|
||||
if success != 1 || conflict != 1 {
|
||||
t.Fatalf("accept/decline success=%d conflict=%d, want 1/1", success, conflict)
|
||||
}
|
||||
|
||||
codeRequest := newRequest(fmt.Sprintf("code-%d", suffix))
|
||||
_, web, err := store.ApproveTelegramLoginRequest(ctx, domain.TelegramLoginApproval{
|
||||
RequestID: codeRequest.ID,
|
||||
Identity: domain.TelegramLoginIdentitySnapshot{UserID: owner.ID, Name: owner.FirstName, GivenName: owner.FirstName},
|
||||
WriteAllowed: true,
|
||||
MatchCode: codeRequest.MatchCode, ApprovedAt: now.Add(2 * time.Second),
|
||||
}, suffix+20_000)
|
||||
if err != nil {
|
||||
t.Fatalf("approve code request: %v", err)
|
||||
}
|
||||
canSend, err := bots.CanBotSendMessage(ctx, bot.ID, owner.ID)
|
||||
if err != nil || !canSend {
|
||||
t.Fatalf("bot access after atomic approval = %v,%v", canSend, err)
|
||||
}
|
||||
code := domain.TelegramLoginAuthorizationCode{
|
||||
RequestID: codeRequest.ID,
|
||||
CodeHash: telegramLoginPGHash(fmt.Sprintf("code-%d", suffix)),
|
||||
SealedCode: append(make([]byte, 32), 1),
|
||||
SealNonce: make([]byte, 12),
|
||||
SealKeyID: "integration-key",
|
||||
IssuedAt: now.Add(3 * time.Second),
|
||||
ExpiresAt: now.Add(time.Minute),
|
||||
}
|
||||
if _, err := store.PutTelegramLoginAuthorizationCode(ctx, code); err != nil {
|
||||
t.Fatalf("put code: %v", err)
|
||||
}
|
||||
exchange := domain.TelegramLoginCodeExchange{
|
||||
CodeHash: code.CodeHash, ClientID: client.ClientID, ClientSecretVersion: client.SecretVersion,
|
||||
RedirectURI: codeRequest.RedirectURI, CodeChallenge: codeRequest.CodeChallenge, Now: now.Add(4 * time.Second),
|
||||
}
|
||||
start = make(chan struct{})
|
||||
errs = make(chan error, 8)
|
||||
var wg sync.WaitGroup
|
||||
for range 8 {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
_, _, _, err := store.ConsumeTelegramLoginAuthorizationCode(ctx, exchange)
|
||||
errs <- err
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
success, conflict = 0, 0
|
||||
for err := range errs {
|
||||
switch {
|
||||
case err == nil:
|
||||
success++
|
||||
case errors.Is(err, domain.ErrTelegramLoginCodeConsumed):
|
||||
conflict++
|
||||
default:
|
||||
t.Fatalf("code consume race error: %v", err)
|
||||
}
|
||||
}
|
||||
if success != 1 || conflict != 7 {
|
||||
t.Fatalf("code consume success=%d consumed=%d, want 1/7", success, conflict)
|
||||
}
|
||||
|
||||
miniRequest, err := store.CreateTelegramLoginRequest(ctx, domain.TelegramLoginRequest{
|
||||
RequestTokenHash: telegramLoginPGHash(fmt.Sprintf("mini-request-%d", suffix)),
|
||||
BrowserTokenHash: telegramLoginPGHash(fmt.Sprintf("mini-browser-%d", suffix)),
|
||||
BotUserID: bot.ID, ClientID: client.ClientID, SigningAlgorithm: client.SigningAlgorithm,
|
||||
Source: domain.TelegramLoginRequestMiniApp, ResponseType: "post_message",
|
||||
RedirectURI: origin + "/", Origin: origin, InAppOrigin: origin, Domain: fmt.Sprintf("rp-%d.example", suffix),
|
||||
Scopes: []domain.TelegramLoginScope{domain.TelegramLoginScopeOpenID, domain.TelegramLoginScopeProfile},
|
||||
Browser: "Telegram Mini App", Platform: "Telegram Mini App", IP: "192.0.2.11", Region: "Test Region",
|
||||
MatchCodes: []string{"🟢", "🔵", "🟠"}, MatchCode: "🔵", MatchCodesFirst: true,
|
||||
Status: domain.TelegramLoginRequestPending, CreatedAt: now, ExpiresAt: now.Add(5 * time.Minute),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create mini-app request: %v", err)
|
||||
}
|
||||
if _, _, err := store.ApproveTelegramLoginRequest(ctx, domain.TelegramLoginApproval{
|
||||
RequestID: miniRequest.ID,
|
||||
Identity: domain.TelegramLoginIdentitySnapshot{UserID: owner.ID, Name: owner.FirstName, GivenName: owner.FirstName},
|
||||
MatchCode: miniRequest.MatchCode, ApprovedAt: now.Add(5 * time.Second),
|
||||
}, suffix+25_000); err != nil {
|
||||
t.Fatalf("approve mini-app request: %v", err)
|
||||
}
|
||||
directToken := domain.TelegramLoginAuthorizationCode{
|
||||
RequestID: miniRequest.ID, CodeHash: telegramLoginPGHash(fmt.Sprintf("mini-token-%d", suffix)),
|
||||
SealedCode: append(make([]byte, 32), 1), SealNonce: make([]byte, 12), SealKeyID: "integration-key",
|
||||
IssuedAt: now.Add(6 * time.Second), ExpiresAt: now.Add(time.Minute),
|
||||
}
|
||||
if _, err := store.PutTelegramLoginAuthorizationCode(ctx, directToken); err != nil {
|
||||
t.Fatalf("put mini-app token: %v", err)
|
||||
}
|
||||
start = make(chan struct{})
|
||||
errs = make(chan error, 8)
|
||||
for range 8 {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
_, _, _, err := store.ConsumeTelegramLoginDirectToken(ctx, directToken.CodeHash, origin, now.Add(7*time.Second))
|
||||
errs <- err
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
success, conflict = 0, 0
|
||||
for err := range errs {
|
||||
switch {
|
||||
case err == nil:
|
||||
success++
|
||||
case errors.Is(err, domain.ErrTelegramLoginCodeConsumed):
|
||||
conflict++
|
||||
default:
|
||||
t.Fatalf("mini-app token consume race error: %v", err)
|
||||
}
|
||||
}
|
||||
if success != 1 || conflict != 7 {
|
||||
t.Fatalf("mini-app token consume success=%d consumed=%d, want 1/7", success, conflict)
|
||||
}
|
||||
|
||||
if revoked, err := store.RevokeTelegramLoginWebAuthorization(ctx, owner.ID, web.Hash, now.Add(5*time.Second)); err != nil || !revoked {
|
||||
t.Fatalf("revoke web authorization = %v,%v", revoked, err)
|
||||
}
|
||||
if listed, err := store.ListTelegramLoginWebAuthorizations(ctx, owner.ID); err != nil {
|
||||
t.Fatalf("list web authorizations: %v", err)
|
||||
} else {
|
||||
for _, got := range listed {
|
||||
if got.Hash == web.Hash {
|
||||
t.Fatalf("revoked web authorization still listed: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
assertTelegramLoginConfigDeleteTakesClientLock(t, pool, client.BotUserID, func() (bool, error) {
|
||||
return store.DeleteTelegramLoginAllowedURL(ctx, client.BotUserID, domain.TelegramLoginAllowedRedirectURI, redirectURI)
|
||||
})
|
||||
}
|
||||
|
||||
func TestTelegramLoginStorePostgresNativeCallbackAndRetention(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
now := time.Now().UTC().Truncate(time.Microsecond)
|
||||
suffix := now.UnixNano() % 1_000_000_000
|
||||
|
||||
users := NewUserStore(pool)
|
||||
bots := NewBotStore(pool)
|
||||
owner, err := users.Create(ctx, domain.User{
|
||||
AccessHash: suffix + 301, Phone: fmt.Sprintf("1666%09d", suffix), FirstName: "Native Owner",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bot, _, err := bots.CreateBotAccount(ctx, domain.User{
|
||||
AccessHash: suffix + 302, FirstName: "Native Login Bot", Username: fmt.Sprintf("native_%09d_bot", suffix),
|
||||
}, domain.BotProfile{OwnerUserID: owner.ID, TokenSecret: "native-bot-secret"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id IN ($1,$2)", owner.ID, bot.ID) })
|
||||
|
||||
store := NewTelegramLoginStore(pool)
|
||||
client, err := store.CreateTelegramLoginClient(ctx, domain.TelegramLoginClient{
|
||||
BotUserID: bot.ID, ClientID: fmt.Sprintf("%d", bot.ID), SecretHash: telegramLoginPGHash("native-secret"),
|
||||
SecretVersion: 1, SigningAlgorithm: domain.TelegramLoginSigningRS256, Enabled: true,
|
||||
CreatedAt: now, UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const callbackURI = "bedolaga://telegram-login"
|
||||
nativeApp, err := store.UpsertTelegramLoginNativeApp(ctx, domain.TelegramLoginNativeApp{
|
||||
BotUserID: bot.ID, Platform: domain.TelegramLoginNativeAndroid, ApplicationID: "dev.bedolaga.demo",
|
||||
VerificationID: strings.Repeat("A", 64), CallbackURI: callbackURI, VerifiedDisplayName: "Bedolaga Demo",
|
||||
Enabled: true, CreatedAt: now, UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
createRequest := func(label string) domain.TelegramLoginRequest {
|
||||
t.Helper()
|
||||
request, err := store.CreateTelegramLoginRequest(ctx, domain.TelegramLoginRequest{
|
||||
RequestTokenHash: telegramLoginPGHash("native-request-" + label), BrowserTokenHash: telegramLoginPGHash("native-browser-" + label),
|
||||
BotUserID: bot.ID, ClientID: client.ClientID, SigningAlgorithm: client.SigningAlgorithm,
|
||||
Source: domain.TelegramLoginRequestNative, ResponseType: "code", RedirectURI: callbackURI,
|
||||
Domain: "dev.bedolaga.demo", Scopes: []domain.TelegramLoginScope{domain.TelegramLoginScopeOpenID, domain.TelegramLoginScopeProfile},
|
||||
CodeChallenge: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", CodeChallengeMethod: "S256",
|
||||
Browser: "TelegramLogin/Android", Platform: "Android", IP: "192.0.2.20", Region: "Test Region",
|
||||
IsApp: true, VerifiedAppName: "Bedolaga Demo", MatchCodes: []string{}, Status: domain.TelegramLoginRequestPending,
|
||||
CreatedAt: now, ExpiresAt: now.Add(5 * time.Minute),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create native request: %v", err)
|
||||
}
|
||||
return request
|
||||
}
|
||||
approve := func(request domain.TelegramLoginRequest, hash int64) domain.TelegramLoginWebAuthorization {
|
||||
t.Helper()
|
||||
_, web, err := store.ApproveTelegramLoginRequest(ctx, domain.TelegramLoginApproval{
|
||||
RequestID: request.ID, Identity: domain.TelegramLoginIdentitySnapshot{UserID: owner.ID, Name: "Native Owner", GivenName: "Native"},
|
||||
ApprovedAt: now.Add(time.Second),
|
||||
}, hash)
|
||||
if err != nil {
|
||||
t.Fatalf("approve native request: %v", err)
|
||||
}
|
||||
return web
|
||||
}
|
||||
|
||||
revokedRequest := createRequest(fmt.Sprintf("revoked-%d", suffix))
|
||||
revokedWeb := approve(revokedRequest, suffix+30_000)
|
||||
code := domain.TelegramLoginAuthorizationCode{
|
||||
RequestID: revokedRequest.ID, CodeHash: telegramLoginPGHash(fmt.Sprintf("native-code-%d", suffix)),
|
||||
SealedCode: append(make([]byte, 32), 1), SealNonce: make([]byte, 12), SealKeyID: "integration-key",
|
||||
IssuedAt: now.Add(2 * time.Second), ExpiresAt: now.Add(time.Minute),
|
||||
}
|
||||
if _, err := store.PutTelegramLoginAuthorizationCode(ctx, code); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, _, err := store.ConsumeTelegramLoginAuthorizationCode(ctx, domain.TelegramLoginCodeExchange{
|
||||
CodeHash: code.CodeHash, ClientID: client.ClientID, ClientSecretVersion: client.SecretVersion,
|
||||
RedirectURI: callbackURI, CodeChallenge: revokedRequest.CodeChallenge, Now: now.Add(3 * time.Second),
|
||||
}); err != nil {
|
||||
t.Fatalf("consume native code: %v", err)
|
||||
}
|
||||
if ok, err := store.RevokeTelegramLoginWebAuthorization(ctx, owner.ID, revokedWeb.Hash, now.Add(4*time.Second)); err != nil || !ok {
|
||||
t.Fatalf("revoke native authorization = %v,%v", ok, err)
|
||||
}
|
||||
|
||||
activeRequest := createRequest(fmt.Sprintf("active-%d", suffix))
|
||||
activeWeb := approve(activeRequest, suffix+40_000)
|
||||
deleted, err := store.DeleteExpiredTelegramLoginArtifacts(ctx, now.Add(2*time.Hour), 100)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if deleted < 2 {
|
||||
t.Fatalf("retention deleted=%d, want at least code and revoked request", deleted)
|
||||
}
|
||||
if _, found, _ := store.GetTelegramLoginRequest(ctx, revokedRequest.ID); found {
|
||||
t.Fatal("revoked native request survived retention")
|
||||
}
|
||||
if _, found, _ := store.GetTelegramLoginRequest(ctx, activeRequest.ID); !found {
|
||||
t.Fatal("active native request was deleted")
|
||||
}
|
||||
listed, err := store.ListTelegramLoginWebAuthorizations(ctx, owner.ID)
|
||||
if err != nil || len(listed) != 1 || listed[0].Hash != activeWeb.Hash {
|
||||
t.Fatalf("active authorization list=%#v err=%v", listed, err)
|
||||
}
|
||||
assertTelegramLoginConfigDeleteTakesClientLock(t, pool, client.BotUserID, func() (bool, error) {
|
||||
return store.DeleteTelegramLoginNativeApp(ctx, client.BotUserID, nativeApp.ID)
|
||||
})
|
||||
}
|
||||
|
||||
func assertTelegramLoginConfigDeleteTakesClientLock(t *testing.T, pool *pgxpool.Pool, botUserID int64, remove func() (bool, error)) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
tx, err := pool.Begin(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
var lockedID int64
|
||||
if err := tx.QueryRow(ctx, `SELECT bot_user_id FROM bot_login_clients WHERE bot_user_id = $1 FOR UPDATE`, botUserID).Scan(&lockedID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result := make(chan error, 1)
|
||||
go func() {
|
||||
deleted, err := remove()
|
||||
if err == nil && !deleted {
|
||||
err = errors.New("configuration row was not deleted")
|
||||
}
|
||||
result <- err
|
||||
}()
|
||||
select {
|
||||
case err := <-result:
|
||||
t.Fatalf("configuration delete bypassed client serialization lock: %v", err)
|
||||
case <-time.After(150 * time.Millisecond):
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case err := <-result:
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("configuration delete remained blocked after client lock committed")
|
||||
}
|
||||
}
|
||||
49
internal/store/telegram_login.go
Normal file
49
internal/store/telegram_login.go
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// TelegramLoginStore is the single durable boundary shared by the HTTP OIDC
|
||||
// adapter, MTProto URL-authorization RPCs and account Web-authorization RPCs.
|
||||
// Implementations must use compare-and-set transitions and must not treat an
|
||||
// in-memory cache as the source of truth.
|
||||
type TelegramLoginStore interface {
|
||||
CreateTelegramLoginClient(ctx context.Context, client domain.TelegramLoginClient) (domain.TelegramLoginClient, error)
|
||||
UpsertTelegramLoginClient(ctx context.Context, client domain.TelegramLoginClient) (domain.TelegramLoginClient, error)
|
||||
GetTelegramLoginClient(ctx context.Context, clientID string) (domain.TelegramLoginClient, bool, error)
|
||||
GetTelegramLoginClientByBot(ctx context.Context, botUserID int64) (domain.TelegramLoginClient, bool, error)
|
||||
RotateTelegramLoginClientSecret(ctx context.Context, botUserID, expectedVersion int64, secretHash []byte, now time.Time) (domain.TelegramLoginClient, error)
|
||||
SetTelegramLoginClientSigningAlgorithm(ctx context.Context, botUserID int64, algorithm domain.TelegramLoginSigningAlgorithm, now time.Time) (domain.TelegramLoginClient, error)
|
||||
SetTelegramLoginClientEnabled(ctx context.Context, botUserID int64, enabled bool, now time.Time) error
|
||||
|
||||
AddTelegramLoginAllowedURL(ctx context.Context, allowed domain.TelegramLoginAllowedURL) (domain.TelegramLoginAllowedURL, error)
|
||||
DeleteTelegramLoginAllowedURL(ctx context.Context, botUserID int64, kind domain.TelegramLoginAllowedURLKind, normalizedURL string) (bool, error)
|
||||
ListTelegramLoginAllowedURLs(ctx context.Context, botUserID int64) ([]domain.TelegramLoginAllowedURL, error)
|
||||
IsTelegramLoginURLAllowed(ctx context.Context, botUserID int64, kind domain.TelegramLoginAllowedURLKind, normalizedURL string) (bool, error)
|
||||
|
||||
UpsertTelegramLoginNativeApp(ctx context.Context, app domain.TelegramLoginNativeApp) (domain.TelegramLoginNativeApp, error)
|
||||
DeleteTelegramLoginNativeApp(ctx context.Context, botUserID, appID int64) (bool, error)
|
||||
ListTelegramLoginNativeApps(ctx context.Context, botUserID int64) ([]domain.TelegramLoginNativeApp, error)
|
||||
|
||||
CreateTelegramLoginRequest(ctx context.Context, request domain.TelegramLoginRequest) (domain.TelegramLoginRequest, error)
|
||||
GetTelegramLoginRequest(ctx context.Context, requestID int64) (domain.TelegramLoginRequest, bool, error)
|
||||
GetTelegramLoginRequestByTokenHash(ctx context.Context, tokenHash []byte) (domain.TelegramLoginRequest, bool, error)
|
||||
GetTelegramLoginRequestByBrowserTokenHash(ctx context.Context, tokenHash []byte) (domain.TelegramLoginRequest, bool, error)
|
||||
ApproveTelegramLoginRequest(ctx context.Context, approval domain.TelegramLoginApproval, webAuthorizationHash int64) (domain.TelegramLoginRequest, domain.TelegramLoginWebAuthorization, error)
|
||||
DeclineTelegramLoginRequest(ctx context.Context, requestID, userID int64, now time.Time) (domain.TelegramLoginRequest, error)
|
||||
|
||||
PutTelegramLoginAuthorizationCode(ctx context.Context, code domain.TelegramLoginAuthorizationCode) (domain.TelegramLoginAuthorizationCode, error)
|
||||
GetTelegramLoginAuthorizationCodeByRequest(ctx context.Context, requestID int64) (domain.TelegramLoginAuthorizationCode, bool, error)
|
||||
GetTelegramLoginAuthorizationCodeByHash(ctx context.Context, codeHash []byte) (domain.TelegramLoginAuthorizationCode, bool, error)
|
||||
ConsumeTelegramLoginAuthorizationCode(ctx context.Context, exchange domain.TelegramLoginCodeExchange) (domain.TelegramLoginAuthorizationCode, domain.TelegramLoginRequest, domain.TelegramLoginWebAuthorization, error)
|
||||
ConsumeTelegramLoginDirectToken(ctx context.Context, tokenHash []byte, origin string, now time.Time) (domain.TelegramLoginAuthorizationCode, domain.TelegramLoginRequest, domain.TelegramLoginWebAuthorization, error)
|
||||
|
||||
ListTelegramLoginWebAuthorizations(ctx context.Context, userID int64) ([]domain.TelegramLoginWebAuthorization, error)
|
||||
RevokeTelegramLoginWebAuthorization(ctx context.Context, userID, hash int64, now time.Time) (bool, error)
|
||||
RevokeAllTelegramLoginWebAuthorizations(ctx context.Context, userID int64, now time.Time) (int64, error)
|
||||
DeleteExpiredTelegramLoginArtifacts(ctx context.Context, before time.Time, limit int) (int64, error)
|
||||
}
|
||||
748
internal/telegramloginhttp/handler.go
Normal file
748
internal/telegramloginhttp/handler.go
Normal file
|
|
@ -0,0 +1,748 @@
|
|||
// Package telegramloginhttp is the public HTTP adapter for Telegram Login and
|
||||
// OpenID Connect. It contains protocol parsing/rendering only; durable state
|
||||
// and authorization transitions remain in app/telegramlogin and its store.
|
||||
package telegramloginhttp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"mime"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
loginapp "telesrv/internal/app/telegramlogin"
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
const (
|
||||
maxAuthorizationQueryBytes = 16 << 10
|
||||
maxTokenFormBytes = 16 << 10
|
||||
maxStatusFormBytes = 4 << 10
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Service *loginapp.Service
|
||||
Tokens *loginapp.IDTokenIssuer
|
||||
Limiter RateLimiter
|
||||
AppName string
|
||||
Logger *zap.Logger
|
||||
TrustedProxyCIDRs []string
|
||||
AllowLoopbackHTTP bool
|
||||
}
|
||||
|
||||
type Handler struct {
|
||||
service *loginapp.Service
|
||||
tokens *loginapp.IDTokenIssuer
|
||||
appName string
|
||||
logger *zap.Logger
|
||||
limiter RateLimiter
|
||||
trustedProxies []netip.Prefix
|
||||
allowLoopbackHTTP bool
|
||||
mux *http.ServeMux
|
||||
}
|
||||
|
||||
type RateLimiter interface {
|
||||
Allow(ctx context.Context, key string, limit int, window time.Duration) (allowed bool, retryAfterSeconds int, err error)
|
||||
}
|
||||
|
||||
func NewHandler(cfg Config) (*Handler, error) {
|
||||
if cfg.Service == nil || cfg.Tokens == nil || cfg.Tokens.Issuer() == "" {
|
||||
return nil, errors.New("telegram login HTTP dependencies are incomplete")
|
||||
}
|
||||
if strings.TrimSpace(cfg.AppName) == "" {
|
||||
cfg.AppName = "Telesrv"
|
||||
}
|
||||
if cfg.Logger == nil {
|
||||
cfg.Logger = zap.NewNop()
|
||||
}
|
||||
trustedProxies := make([]netip.Prefix, 0, len(cfg.TrustedProxyCIDRs))
|
||||
for _, raw := range cfg.TrustedProxyCIDRs {
|
||||
prefix, err := netip.ParsePrefix(strings.TrimSpace(raw))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("telegram login trusted proxy CIDR %q: %w", raw, err)
|
||||
}
|
||||
trustedProxies = append(trustedProxies, prefix.Masked())
|
||||
}
|
||||
h := &Handler{service: cfg.Service, tokens: cfg.Tokens, appName: strings.TrimSpace(cfg.AppName), logger: cfg.Logger, limiter: cfg.Limiter, trustedProxies: trustedProxies, allowLoopbackHTTP: cfg.AllowLoopbackHTTP}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /.well-known/openid-configuration", h.discovery)
|
||||
mux.HandleFunc("GET /.well-known/jwks.json", h.jwks)
|
||||
mux.HandleFunc("GET /auth", h.authorize)
|
||||
mux.HandleFunc("GET /crossapp", h.crossApp)
|
||||
mux.HandleFunc("GET /inapp", h.inApp)
|
||||
mux.HandleFunc("POST /auth/status", h.authorizationStatus)
|
||||
mux.HandleFunc("POST /token", h.token)
|
||||
mux.HandleFunc("GET /telegram-login.js", h.loginJavaScript)
|
||||
mux.HandleFunc("GET /js/telegram-login.js", h.loginJavaScript)
|
||||
h.mux = mux
|
||||
return h, nil
|
||||
}
|
||||
|
||||
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("Referrer-Policy", "no-referrer")
|
||||
w.Header().Set("Cross-Origin-Opener-Policy", "same-origin-allow-popups")
|
||||
h.mux.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
type discoveryDocument struct {
|
||||
Issuer string `json:"issuer"`
|
||||
AuthorizationEndpoint string `json:"authorization_endpoint"`
|
||||
TokenEndpoint string `json:"token_endpoint"`
|
||||
JWKSURI string `json:"jwks_uri"`
|
||||
ScopesSupported []string `json:"scopes_supported"`
|
||||
ResponseTypesSupported []string `json:"response_types_supported"`
|
||||
ResponseModesSupported []string `json:"response_modes_supported"`
|
||||
GrantTypesSupported []string `json:"grant_types_supported"`
|
||||
SubjectTypesSupported []string `json:"subject_types_supported"`
|
||||
IDTokenSigningAlgorithms []string `json:"id_token_signing_alg_values_supported"`
|
||||
TokenEndpointAuthMethods []string `json:"token_endpoint_auth_methods_supported"`
|
||||
ClaimsSupported []string `json:"claims_supported"`
|
||||
CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"`
|
||||
}
|
||||
|
||||
func (h *Handler) discovery(w http.ResponseWriter, _ *http.Request) {
|
||||
issuer := h.tokens.Issuer()
|
||||
writeJSON(w, http.StatusOK, discoveryDocument{
|
||||
Issuer: issuer, AuthorizationEndpoint: issuer + "/auth", TokenEndpoint: issuer + "/token",
|
||||
JWKSURI: issuer + "/.well-known/jwks.json",
|
||||
ScopesSupported: []string{"openid", "profile", "phone", "telegram:bot_access"},
|
||||
ResponseTypesSupported: []string{"code"}, ResponseModesSupported: []string{"query"},
|
||||
GrantTypesSupported: []string{"authorization_code"}, SubjectTypesSupported: []string{"public"},
|
||||
IDTokenSigningAlgorithms: h.tokens.SupportedAlgorithms(),
|
||||
TokenEndpointAuthMethods: []string{"client_secret_basic", "client_secret_post", "none"},
|
||||
ClaimsSupported: []string{
|
||||
"iss", "aud", "sub", "iat", "exp", "nonce", "id", "name", "given_name",
|
||||
"family_name", "preferred_username", "picture", "phone_number", "phone_number_verified",
|
||||
},
|
||||
CodeChallengeMethodsSupported: []string{"S256"},
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) jwks(w http.ResponseWriter, r *http.Request) {
|
||||
body, etag, err := h.tokens.JWKS()
|
||||
if err != nil {
|
||||
h.logger.Error("telegram_login_jwks_failed", zap.Error(err))
|
||||
writeOAuthError(w, http.StatusInternalServerError, "server_error", "key service unavailable")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "public, max-age=300, must-revalidate")
|
||||
w.Header().Set("ETag", etag)
|
||||
if r.Header.Get("If-None-Match") == etag {
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
|
||||
type authorizationPageData struct {
|
||||
AppName string
|
||||
DeepLink template.URL
|
||||
MatchCode string
|
||||
BrowserToken string
|
||||
ExpiresAt string
|
||||
CSPNonce string
|
||||
ResponseType string
|
||||
TargetOrigin string
|
||||
}
|
||||
|
||||
var authorizationPage = template.Must(template.New("telegram-login").Parse(`<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Log in with {{.AppName}}</title><style>
|
||||
:root{color-scheme:light dark}body{font:16px/1.45 system-ui,sans-serif;margin:0;background:#17212b;color:#fff}.card{max-width:460px;margin:10vh auto;padding:28px;border-radius:18px;background:#202b36;box-shadow:0 16px 48px #0006}h1{margin:.1em 0 .5em}.button{display:block;text-align:center;margin:24px 0;padding:13px 18px;border-radius:11px;background:#2aabee;color:#fff;text-decoration:none;font-weight:700}.match{text-align:center;margin:22px 0;padding:18px;border-radius:14px;background:#17212b}.match p{margin:0 0 8px}.match-code{font-size:44px;line-height:1.2}.muted{color:#a9b5c1;font-size:14px}.error{color:#ff8d8d}</style></head>
|
||||
<body><main class="card"><h1>Log in with {{.AppName}}</h1><p>Open the {{.AppName}} app and approve this request. Keep this page open.</p><a class="button" href="{{.DeepLink}}">Open {{.AppName}}</a>{{if .MatchCode}}<section class="match" aria-labelledby="match-title"><p id="match-title">When prompted, select this emoji in {{.AppName}}:</p><div id="match-code" class="match-code" role="img" aria-label="Matching emoji">{{.MatchCode}}</div></section>{{end}}<p id="status" class="muted">Waiting for approval…</p><p class="muted">This request expires at {{.ExpiresAt}}.</p></main>
|
||||
<script nonce="{{.CSPNonce}}">const token={{.BrowserToken}},responseType={{.ResponseType}},targetOrigin={{.TargetOrigin}};const statusNode=document.getElementById('status');function deliver(data){if(!window.opener||!targetOrigin)throw new Error('missing_opener');const payload=data.id_token?{event:'auth_result',result:data.id_token}:{event:'auth_result',error:data.error||data.status};window.opener.postMessage(payload,targetOrigin);window.close()}async function poll(){try{const body=new URLSearchParams({browser_token:token});const response=await fetch('/auth/status',{method:'POST',headers:{'content-type':'application/x-www-form-urlencoded'},body,cache:'no-store'});const data=await response.json();if(!response.ok){throw new Error(data.error||'request_failed')}if(data.status==='pending'){setTimeout(poll,1000);return}if(responseType==='post_message'){deliver(data);return}if(data.redirect_url){location.replace(data.redirect_url);return}throw new Error('invalid_response')}catch(error){statusNode.className='error';statusNode.textContent='Login status unavailable. Please restart the login flow.'}}poll();</script></body></html>`))
|
||||
|
||||
func (h *Handler) authorize(w http.ResponseWriter, r *http.Request) {
|
||||
clientIP := h.requestIP(r)
|
||||
if !h.allow(w, r, "authorize", clientIP, 30, time.Minute) {
|
||||
return
|
||||
}
|
||||
if len(r.URL.RawQuery) > maxAuthorizationQueryBytes {
|
||||
h.authorizationRequestError(w)
|
||||
return
|
||||
}
|
||||
query := r.URL.Query()
|
||||
nativePlatform, nativeMarkerOK := nativeSDKPlatform(query)
|
||||
if !nativeMarkerOK {
|
||||
h.authorizationRequestError(w)
|
||||
return
|
||||
}
|
||||
values := make(map[string]string, 9)
|
||||
for _, key := range []string{"client_id", "redirect_uri", "response_type", "scope", "state", "nonce", "code_challenge", "code_challenge_method", "origin"} {
|
||||
value, ok := singleValue(query, key)
|
||||
if !ok {
|
||||
h.authorizationRequestError(w)
|
||||
return
|
||||
}
|
||||
values[key] = value
|
||||
}
|
||||
platformLabel := "Web"
|
||||
if nativePlatform == domain.TelegramLoginNativeIOS {
|
||||
platformLabel = "iOS"
|
||||
} else if nativePlatform == domain.TelegramLoginNativeAndroid {
|
||||
platformLabel = "Android"
|
||||
}
|
||||
source := domain.TelegramLoginRequestWeb
|
||||
if values["response_type"] == "post_message" {
|
||||
source = domain.TelegramLoginRequestJavaScript
|
||||
}
|
||||
created, err := h.service.CreateAuthorization(r.Context(), loginapp.CreateAuthorizationParams{
|
||||
ClientID: values["client_id"], RedirectURI: values["redirect_uri"], ResponseType: values["response_type"],
|
||||
Scope: values["scope"], State: values["state"], Nonce: values["nonce"],
|
||||
CodeChallenge: values["code_challenge"], CodeChallengeMethod: values["code_challenge_method"],
|
||||
Origin: values["origin"],
|
||||
Source: source, NativePlatform: nativePlatform,
|
||||
Browser: boundedHeader(r.UserAgent(), "Unknown browser", 255),
|
||||
Platform: platformLabel, IP: clientIP, Region: "Unknown region", IncludeMatchCodes: true, MatchCodesFirst: true,
|
||||
})
|
||||
if err != nil {
|
||||
h.logger.Info("telegram_login_authorize_rejected", zap.String("error", errorClass(err)))
|
||||
h.authorizationError(w, r, values, err)
|
||||
return
|
||||
}
|
||||
cspNonce, err := loginapp.GenerateOpaqueToken()
|
||||
if err != nil {
|
||||
h.logger.Error("telegram_login_csp_nonce_failed", zap.Error(err))
|
||||
h.authorizationRequestError(w)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Pragma", "no-cache")
|
||||
w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; script-src 'nonce-"+cspNonce+"'; connect-src 'self'; form-action 'none'; frame-ancestors 'none'; base-uri 'none'")
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if err := authorizationPage.Execute(w, authorizationPageData{
|
||||
AppName: h.appName, DeepLink: template.URL(created.DeepLink), MatchCode: created.Request.MatchCode, BrowserToken: created.BrowserToken,
|
||||
ExpiresAt: created.Request.ExpiresAt.UTC().Format(time.RFC3339), CSPNonce: cspNonce,
|
||||
ResponseType: created.Request.ResponseType, TargetOrigin: created.Request.Origin,
|
||||
}); err != nil {
|
||||
h.logger.Warn("telegram_login_authorize_render_failed", zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) crossApp(w http.ResponseWriter, r *http.Request) {
|
||||
clientIP := h.requestIP(r)
|
||||
if !h.allow(w, r, "crossapp", clientIP, 30, time.Minute) {
|
||||
return
|
||||
}
|
||||
if len(r.URL.RawQuery) > maxAuthorizationQueryBytes {
|
||||
writeOAuthError(w, http.StatusBadRequest, "invalid_request", "invalid native login request")
|
||||
return
|
||||
}
|
||||
query := r.URL.Query()
|
||||
platform, ok := nativeSDKPlatform(query)
|
||||
if !ok || !platform.Valid() {
|
||||
writeOAuthError(w, http.StatusBadRequest, "invalid_request", "exactly one native SDK marker is required")
|
||||
return
|
||||
}
|
||||
values := make(map[string]string, 8)
|
||||
for _, key := range []string{"client_id", "redirect_uri", "response_type", "scope", "state", "nonce", "code_challenge", "code_challenge_method"} {
|
||||
value, unique := singleValue(query, key)
|
||||
if !unique {
|
||||
writeOAuthError(w, http.StatusBadRequest, "invalid_request", "duplicate parameter")
|
||||
return
|
||||
}
|
||||
values[key] = value
|
||||
}
|
||||
platformLabel := "iOS"
|
||||
if platform == domain.TelegramLoginNativeAndroid {
|
||||
platformLabel = "Android"
|
||||
}
|
||||
created, err := h.service.CreateAuthorization(r.Context(), loginapp.CreateAuthorizationParams{
|
||||
ClientID: values["client_id"], RedirectURI: values["redirect_uri"], ResponseType: values["response_type"],
|
||||
Scope: values["scope"], State: values["state"], Nonce: values["nonce"],
|
||||
CodeChallenge: values["code_challenge"], CodeChallengeMethod: values["code_challenge_method"],
|
||||
Source: domain.TelegramLoginRequestNative, NativePlatform: platform,
|
||||
Browser: boundedHeader(r.UserAgent(), "Native SDK", 255), Platform: platformLabel,
|
||||
IP: clientIP, Region: "Unknown region", IncludeMatchCodes: true, MatchCodesFirst: true,
|
||||
})
|
||||
if err != nil {
|
||||
h.logger.Info("telegram_login_crossapp_rejected", zap.String("error", errorClass(err)))
|
||||
writeOAuthError(w, http.StatusBadRequest, "invalid_request", "native login request is not registered")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Pragma", "no-cache")
|
||||
writeJSON(w, http.StatusOK, map[string]string{"url": created.DeepLink})
|
||||
}
|
||||
|
||||
func (h *Handler) inApp(w http.ResponseWriter, r *http.Request) {
|
||||
clientIP := h.requestIP(r)
|
||||
if !h.allow(w, r, "inapp", clientIP, 60, time.Minute) {
|
||||
return
|
||||
}
|
||||
if len(r.URL.RawQuery) > maxAuthorizationQueryBytes {
|
||||
writeOAuthError(w, http.StatusBadRequest, "invalid_request", "invalid in-app login request")
|
||||
return
|
||||
}
|
||||
query := r.URL.Query()
|
||||
if rawCode, exists := query["code"]; exists {
|
||||
if len(query) != 1 || len(rawCode) != 1 || rawCode[0] == "" {
|
||||
writeOAuthError(w, http.StatusBadRequest, "invalid_request", "invalid in-app token")
|
||||
return
|
||||
}
|
||||
issued, err := h.service.ExchangeInAppTokenAndIssue(r.Context(), rawCode[0], r.Header.Get("Origin"), h.tokens)
|
||||
if err != nil {
|
||||
h.logger.Info("telegram_login_inapp_exchange_rejected", zap.String("error", errorClass(err)))
|
||||
writeOAuthError(w, http.StatusBadRequest, "invalid_grant", "in-app token is invalid or expired")
|
||||
return
|
||||
}
|
||||
setInAppCORS(w, issued.Request.InAppOrigin)
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Pragma", "no-cache")
|
||||
writeJSON(w, http.StatusOK, map[string]string{"result": issued.IDToken})
|
||||
return
|
||||
}
|
||||
if len(query) != 4 {
|
||||
writeOAuthError(w, http.StatusBadRequest, "invalid_request", "invalid in-app login request")
|
||||
return
|
||||
}
|
||||
values := make(map[string]string, 4)
|
||||
for _, key := range []string{"client_id", "scope", "origin", "response_type"} {
|
||||
value, unique := singleValue(query, key)
|
||||
if !unique || value == "" {
|
||||
writeOAuthError(w, http.StatusBadRequest, "invalid_request", "invalid in-app login request")
|
||||
return
|
||||
}
|
||||
values[key] = value
|
||||
}
|
||||
if values["response_type"] != "id_token" {
|
||||
writeOAuthError(w, http.StatusBadRequest, "unsupported_response_type", "only id_token is supported")
|
||||
return
|
||||
}
|
||||
origin, err := loginapp.NormalizeWebOrigin(values["origin"], h.allowLoopbackHTTP)
|
||||
if err != nil || r.Header.Get("Origin") != origin {
|
||||
writeOAuthError(w, http.StatusBadRequest, "invalid_request", "in-app origin is invalid")
|
||||
return
|
||||
}
|
||||
setInAppCORS(w, origin)
|
||||
created, err := h.service.CreateAuthorization(r.Context(), loginapp.CreateAuthorizationParams{
|
||||
ClientID: values["client_id"], RedirectURI: origin + "/", ResponseType: "post_message",
|
||||
Scope: values["scope"], Origin: origin, InAppOrigin: origin,
|
||||
Source: domain.TelegramLoginRequestMiniApp,
|
||||
Browser: boundedHeader(r.UserAgent(), "Telegram Mini App", 255), Platform: "Telegram Mini App",
|
||||
IP: clientIP, Region: "Unknown region", IncludeMatchCodes: true, MatchCodesFirst: true,
|
||||
})
|
||||
if err != nil {
|
||||
h.logger.Info("telegram_login_inapp_rejected", zap.String("error", errorClass(err)))
|
||||
writeOAuthError(w, http.StatusBadRequest, "invalid_request", "in-app login request is not registered")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Pragma", "no-cache")
|
||||
writeJSON(w, http.StatusOK, map[string]string{"url": created.DeepLink})
|
||||
}
|
||||
|
||||
func setInAppCORS(w http.ResponseWriter, origin string) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
w.Header().Set("Vary", "Origin")
|
||||
}
|
||||
|
||||
func nativeSDKPlatform(values url.Values) (domain.TelegramLoginNativePlatform, bool) {
|
||||
ios, iosUnique := singleValue(values, "ios_sdk")
|
||||
android, androidUnique := singleValue(values, "android_sdk")
|
||||
if !iosUnique || !androidUnique || (ios != "" && ios != "1") || (android != "" && android != "1") || (ios != "" && android != "") {
|
||||
return "", false
|
||||
}
|
||||
if ios == "1" {
|
||||
return domain.TelegramLoginNativeIOS, true
|
||||
}
|
||||
if android == "1" {
|
||||
return domain.TelegramLoginNativeAndroid, true
|
||||
}
|
||||
return "", true
|
||||
}
|
||||
|
||||
var authorizationErrorPage = template.Must(template.New("telegram-login-error").Parse(`<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Telegram Login</title></head><body><p>Login could not be started.</p><script nonce="{{.Nonce}}">if(window.opener){window.opener.postMessage({event:'auth_result',error:{{.Error}}},{{.Origin}});window.close();}</script></body></html>`))
|
||||
|
||||
func (h *Handler) authorizationError(w http.ResponseWriter, r *http.Request, values map[string]string, cause error) {
|
||||
target, safe, err := h.service.ResolveAuthorizationErrorTarget(r.Context(), values["client_id"], values["response_type"], values["redirect_uri"], values["origin"])
|
||||
if err != nil || !safe {
|
||||
if err != nil {
|
||||
h.logger.Error("telegram_login_authorize_error_target_failed", zap.String("error", errorClass(err)))
|
||||
}
|
||||
h.authorizationRequestError(w)
|
||||
return
|
||||
}
|
||||
code := "invalid_request"
|
||||
switch {
|
||||
case errors.Is(cause, domain.ErrTelegramLoginScopeInvalid):
|
||||
code = "invalid_scope"
|
||||
case values["response_type"] != "code" && values["response_type"] != "post_message":
|
||||
code = "unsupported_response_type"
|
||||
default:
|
||||
// Known validation failures use invalid_request. Unknown failures are
|
||||
// reported as server_error without exposing their details.
|
||||
if strings.HasPrefix(errorClass(cause), "internal_") {
|
||||
code = "server_error"
|
||||
}
|
||||
}
|
||||
state := values["state"]
|
||||
if len(state) > 2048 {
|
||||
state = ""
|
||||
}
|
||||
if target.ResponseType == "code" {
|
||||
redirectURL, err := loginapp.AppendAuthorizationError(target.RedirectURI, code, state)
|
||||
if err != nil {
|
||||
h.authorizationRequestError(w)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
http.Redirect(w, r, redirectURL, http.StatusFound)
|
||||
return
|
||||
}
|
||||
nonce, err := loginapp.GenerateOpaqueToken()
|
||||
if err != nil {
|
||||
h.authorizationRequestError(w)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Content-Security-Policy", "default-src 'none'; script-src 'nonce-"+nonce+"'; frame-ancestors 'none'; base-uri 'none'")
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = authorizationErrorPage.Execute(w, struct {
|
||||
Nonce string
|
||||
Error string
|
||||
Origin string
|
||||
}{Nonce: nonce, Error: code, Origin: target.Origin})
|
||||
}
|
||||
|
||||
func (h *Handler) authorizationRequestError(w http.ResponseWriter) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
http.Error(w, "Invalid Telegram Login request.", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
func (h *Handler) authorizationStatus(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Pragma", "no-cache")
|
||||
if !h.allow(w, r, "status", h.requestIP(r), 300, time.Minute) {
|
||||
return
|
||||
}
|
||||
form, ok := parseBoundedForm(w, r, maxStatusFormBytes)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
browserToken, unique := singleValue(form, "browser_token")
|
||||
if !unique || browserToken == "" {
|
||||
writeOAuthError(w, http.StatusBadRequest, "invalid_request", "invalid browser request")
|
||||
return
|
||||
}
|
||||
request, err := h.service.RequestByBrowserToken(r.Context(), browserToken)
|
||||
if err != nil {
|
||||
writeOAuthError(w, http.StatusBadRequest, "invalid_request", "invalid browser request")
|
||||
return
|
||||
}
|
||||
switch request.Status {
|
||||
case domain.TelegramLoginRequestPending:
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "pending"})
|
||||
case domain.TelegramLoginRequestApproved:
|
||||
if request.ResponseType == "post_message" {
|
||||
finalized, err := h.service.FinalizeDirectByBrowserToken(r.Context(), browserToken, h.tokens)
|
||||
if err != nil {
|
||||
h.logger.Error("telegram_login_direct_finalize_failed", zap.Int64("request_id", request.ID), zap.Error(err))
|
||||
writeOAuthError(w, http.StatusInternalServerError, "server_error", "authorization could not be finalized")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "approved", "id_token": finalized.IDToken})
|
||||
return
|
||||
}
|
||||
finalized, err := h.service.FinalizeByBrowserToken(r.Context(), browserToken)
|
||||
if err != nil {
|
||||
h.logger.Error("telegram_login_finalize_failed", zap.Int64("request_id", request.ID), zap.Error(err))
|
||||
writeOAuthError(w, http.StatusInternalServerError, "server_error", "authorization could not be finalized")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "approved", "redirect_url": finalized.RedirectURL})
|
||||
case domain.TelegramLoginRequestDeclined, domain.TelegramLoginRequestExpired:
|
||||
errorCode := "access_denied"
|
||||
status := "declined"
|
||||
if request.Status == domain.TelegramLoginRequestExpired {
|
||||
errorCode, status = "temporarily_unavailable", "expired"
|
||||
}
|
||||
if request.ResponseType == "post_message" {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": status, "error": errorCode})
|
||||
return
|
||||
}
|
||||
redirectURL, err := loginapp.AppendAuthorizationError(request.RedirectURI, errorCode, request.State)
|
||||
if err != nil {
|
||||
writeOAuthError(w, http.StatusInternalServerError, "server_error", "authorization could not be finalized")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": status, "redirect_url": redirectURL})
|
||||
default:
|
||||
writeOAuthError(w, http.StatusBadRequest, "invalid_request", "invalid browser request")
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) token(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.allow(w, r, "token-ip", h.requestIP(r), 60, time.Minute) {
|
||||
return
|
||||
}
|
||||
form, ok := parseBoundedForm(w, r, maxTokenFormBytes)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
authorizationHeaders := r.Header.Values("Authorization")
|
||||
var clientID, clientSecret string
|
||||
var publicNativeClient bool
|
||||
switch len(authorizationHeaders) {
|
||||
case 0:
|
||||
var unique bool
|
||||
clientID, unique = requiredSingleValue(form, "client_id", 64)
|
||||
if !unique {
|
||||
h.invalidClient(w)
|
||||
return
|
||||
}
|
||||
clientSecret, unique = optionalSingleValue(form, "client_secret")
|
||||
if !unique || len(clientSecret) > 1024 {
|
||||
h.invalidClient(w)
|
||||
return
|
||||
}
|
||||
publicNativeClient = clientSecret == ""
|
||||
case 1:
|
||||
var ok bool
|
||||
clientID, clientSecret, ok = r.BasicAuth()
|
||||
if !ok || clientID == "" || clientSecret == "" || len(clientID) > 64 || len(clientSecret) > 1024 {
|
||||
h.invalidClient(w)
|
||||
return
|
||||
}
|
||||
if _, supplied := form["client_secret"]; supplied {
|
||||
writeOAuthError(w, http.StatusBadRequest, "invalid_request", "multiple client authentication methods are not allowed")
|
||||
return
|
||||
}
|
||||
default:
|
||||
h.invalidClient(w)
|
||||
return
|
||||
}
|
||||
if !h.allow(w, r, "token-client", clientID, 60, time.Minute) {
|
||||
return
|
||||
}
|
||||
formClientID, unique := optionalSingleValue(form, "client_id")
|
||||
if !unique || (formClientID != "" && formClientID != clientID) {
|
||||
h.invalidClient(w)
|
||||
return
|
||||
}
|
||||
grantType, okGrant := requiredSingleValue(form, "grant_type", 64)
|
||||
if !okGrant {
|
||||
writeOAuthError(w, http.StatusBadRequest, "invalid_request", "grant_type is required")
|
||||
return
|
||||
}
|
||||
if grantType != "authorization_code" {
|
||||
writeOAuthError(w, http.StatusBadRequest, "unsupported_grant_type", "only authorization_code is supported")
|
||||
return
|
||||
}
|
||||
code, okCode := requiredSingleValue(form, "code", 1024)
|
||||
redirectURI, okRedirect := requiredSingleValue(form, "redirect_uri", 4096)
|
||||
codeVerifier, okVerifier := requiredSingleValue(form, "code_verifier", 128)
|
||||
if !okCode || !okRedirect || !okVerifier {
|
||||
writeOAuthError(w, http.StatusBadRequest, "invalid_request", "code, redirect_uri and code_verifier are required")
|
||||
return
|
||||
}
|
||||
// Generate every response artifact before consuming the one-time code. A
|
||||
// transient entropy failure must leave the grant retryable.
|
||||
accessToken, err := loginapp.GenerateOpaqueToken()
|
||||
if err != nil {
|
||||
h.logger.Error("telegram_login_access_token_failed", zap.Error(err))
|
||||
writeOAuthError(w, http.StatusInternalServerError, "server_error", "token service unavailable")
|
||||
return
|
||||
}
|
||||
issued, err := h.service.ExchangeAuthorizationCodeAndIssue(r.Context(), loginapp.ExchangeAuthorizationCodeParams{
|
||||
Code: code, ClientID: clientID, ClientSecret: clientSecret, RedirectURI: redirectURI, CodeVerifier: codeVerifier,
|
||||
PublicNativeClient: publicNativeClient,
|
||||
}, h.tokens)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, domain.ErrTelegramLoginSecretInvalid), errors.Is(err, domain.ErrTelegramLoginClientDisabled):
|
||||
h.invalidClient(w)
|
||||
case errors.Is(err, domain.ErrTelegramLoginCodeInvalid), errors.Is(err, domain.ErrTelegramLoginCodeConsumed),
|
||||
errors.Is(err, domain.ErrTelegramLoginPKCEInvalid), errors.Is(err, domain.ErrTelegramLoginURLInvalid):
|
||||
writeOAuthError(w, http.StatusBadRequest, "invalid_grant", "authorization code is invalid or expired")
|
||||
default:
|
||||
h.logger.Error("telegram_login_token_exchange_failed", zap.String("error", errorClass(err)))
|
||||
writeOAuthError(w, http.StatusInternalServerError, "server_error", "token service unavailable")
|
||||
}
|
||||
return
|
||||
}
|
||||
_ = issued.WebAuthorization // durable authorization is intentionally not encoded into the opaque access token.
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Pragma", "no-cache")
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"access_token": accessToken, "token_type": "Bearer", "expires_in": int64(h.tokens.TTL().Seconds()),
|
||||
"id_token": issued.IDToken,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) invalidClient(w http.ResponseWriter) {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="telegram-login-token", charset="UTF-8"`)
|
||||
writeOAuthError(w, http.StatusUnauthorized, "invalid_client", "client authentication failed")
|
||||
}
|
||||
|
||||
func parseBoundedForm(w http.ResponseWriter, r *http.Request, maxBytes int64) (url.Values, bool) {
|
||||
mediaType, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
|
||||
if err != nil || mediaType != "application/x-www-form-urlencoded" {
|
||||
writeOAuthError(w, http.StatusUnsupportedMediaType, "invalid_request", "form content type is required")
|
||||
return nil, false
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxBytes)
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
writeOAuthError(w, http.StatusBadRequest, "invalid_request", "request body is invalid")
|
||||
return nil, false
|
||||
}
|
||||
form, err := url.ParseQuery(string(body))
|
||||
if err != nil {
|
||||
writeOAuthError(w, http.StatusBadRequest, "invalid_request", "request body is invalid")
|
||||
return nil, false
|
||||
}
|
||||
return form, true
|
||||
}
|
||||
|
||||
func singleValue(values url.Values, key string) (string, bool) {
|
||||
items, exists := values[key]
|
||||
if !exists {
|
||||
return "", true
|
||||
}
|
||||
if len(items) != 1 {
|
||||
return "", false
|
||||
}
|
||||
return items[0], true
|
||||
}
|
||||
|
||||
func optionalSingleValue(values url.Values, key string) (string, bool) {
|
||||
value, ok := singleValue(values, key)
|
||||
return value, ok
|
||||
}
|
||||
|
||||
func requiredSingleValue(values url.Values, key string, max int) (string, bool) {
|
||||
value, ok := singleValue(values, key)
|
||||
return value, ok && value != "" && len(value) <= max
|
||||
}
|
||||
|
||||
func boundedHeader(value, fallback string, max int) string {
|
||||
value = strings.TrimSpace(strings.ToValidUTF8(value, "<22>"))
|
||||
if value == "" {
|
||||
value = fallback
|
||||
}
|
||||
for len(value) > max {
|
||||
_, size := utf8.DecodeLastRuneInString(value)
|
||||
value = value[:len(value)-size]
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func (h *Handler) requestIP(r *http.Request) string {
|
||||
remote, ok := parseRequestIP(r.RemoteAddr)
|
||||
if !ok {
|
||||
return "Unknown IP"
|
||||
}
|
||||
if !prefixContains(h.trustedProxies, remote) {
|
||||
return remote.String()
|
||||
}
|
||||
forwarded := strings.Split(r.Header.Get("X-Forwarded-For"), ",")
|
||||
for i := len(forwarded) - 1; i >= 0; i-- {
|
||||
candidate, ok := parseRequestIP(strings.TrimSpace(forwarded[i]))
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
remote = candidate
|
||||
if !prefixContains(h.trustedProxies, candidate) {
|
||||
return candidate.String()
|
||||
}
|
||||
}
|
||||
if candidate, ok := parseRequestIP(strings.TrimSpace(r.Header.Get("X-Real-IP"))); ok {
|
||||
return candidate.String()
|
||||
}
|
||||
return remote.String()
|
||||
}
|
||||
|
||||
func parseRequestIP(raw string) (netip.Addr, bool) {
|
||||
if addrPort, err := netip.ParseAddrPort(raw); err == nil {
|
||||
return addrPort.Addr().Unmap(), true
|
||||
}
|
||||
if host, _, err := net.SplitHostPort(raw); err == nil {
|
||||
raw = host
|
||||
}
|
||||
addr, err := netip.ParseAddr(strings.Trim(raw, "[]"))
|
||||
return addr.Unmap(), err == nil
|
||||
}
|
||||
|
||||
func prefixContains(prefixes []netip.Prefix, addr netip.Addr) bool {
|
||||
for _, prefix := range prefixes {
|
||||
if prefix.Contains(addr) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (h *Handler) allow(w http.ResponseWriter, r *http.Request, bucket, subject string, limit int, window time.Duration) bool {
|
||||
if h.limiter == nil {
|
||||
return true
|
||||
}
|
||||
sum := sha256.Sum256([]byte(subject))
|
||||
key := "telegram-login-http:" + bucket + ":" + base64.RawURLEncoding.EncodeToString(sum[:])
|
||||
allowed, retryAfter, err := h.limiter.Allow(r.Context(), key, limit, window)
|
||||
if err != nil {
|
||||
h.logger.Error("telegram_login_rate_limit_failed", zap.String("bucket", bucket), zap.Error(err))
|
||||
writeOAuthError(w, http.StatusServiceUnavailable, "temporarily_unavailable", "login service temporarily unavailable")
|
||||
return false
|
||||
}
|
||||
if allowed {
|
||||
return true
|
||||
}
|
||||
if retryAfter <= 0 {
|
||||
retryAfter = 1
|
||||
}
|
||||
w.Header().Set("Retry-After", fmt.Sprintf("%d", retryAfter))
|
||||
writeOAuthError(w, http.StatusTooManyRequests, "temporarily_unavailable", "too many login requests")
|
||||
return false
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, value any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
}
|
||||
|
||||
func writeOAuthError(w http.ResponseWriter, status int, code, description string) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Pragma", "no-cache")
|
||||
writeJSON(w, status, map[string]string{"error": code, "error_description": description})
|
||||
}
|
||||
|
||||
func errorClass(err error) string {
|
||||
for _, candidate := range []struct {
|
||||
target error
|
||||
name string
|
||||
}{
|
||||
{domain.ErrTelegramLoginClientInvalid, "client_invalid"},
|
||||
{domain.ErrTelegramLoginClientDisabled, "client_disabled"},
|
||||
{domain.ErrTelegramLoginRedirectNotAllowed, "redirect_not_allowed"},
|
||||
{domain.ErrTelegramLoginOriginNotAllowed, "origin_not_allowed"},
|
||||
{domain.ErrTelegramLoginScopeInvalid, "scope_invalid"},
|
||||
{domain.ErrTelegramLoginPKCEInvalid, "pkce_invalid"},
|
||||
{domain.ErrTelegramLoginRequestInvalid, "request_invalid"},
|
||||
} {
|
||||
if errors.Is(err, candidate.target) {
|
||||
return candidate.name
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("internal_%T", err)
|
||||
}
|
||||
675
internal/telegramloginhttp/handler_test.go
Normal file
675
internal/telegramloginhttp/handler_test.go
Normal file
|
|
@ -0,0 +1,675 @@
|
|||
package telegramloginhttp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"encoding/json"
|
||||
"html"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/lestrrat-go/jwx/v3/jwk"
|
||||
"github.com/lestrrat-go/jwx/v3/jwt"
|
||||
|
||||
loginapp "telesrv/internal/app/telegramlogin"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
func TestRequestIPTrustsForwardingHeadersOnlyFromConfiguredProxies(t *testing.T) {
|
||||
h := &Handler{trustedProxies: []netip.Prefix{netip.MustParsePrefix("127.0.0.1/32"), netip.MustParsePrefix("10.0.0.0/8")}}
|
||||
req := httptest.NewRequest(http.MethodGet, "https://oauth.test/auth", nil)
|
||||
req.RemoteAddr = "127.0.0.1:44321"
|
||||
req.Header.Set("X-Forwarded-For", "198.51.100.7, 10.0.0.4")
|
||||
if got := h.requestIP(req); got != "198.51.100.7" {
|
||||
t.Fatalf("trusted proxy client IP = %q", got)
|
||||
}
|
||||
req.RemoteAddr = "203.0.113.9:44321"
|
||||
req.Header.Set("X-Forwarded-For", "198.51.100.8")
|
||||
if got := h.requestIP(req); got != "203.0.113.9" {
|
||||
t.Fatalf("untrusted spoofed client IP = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoundedHeaderPreservesValidUTF8AtByteLimit(t *testing.T) {
|
||||
got := boundedHeader(strings.Repeat("界", 100)+string([]byte{0xff}), "fallback", 255)
|
||||
if !utf8.ValidString(got) || len(got) > 255 || got == "" {
|
||||
t.Fatalf("bounded header len=%d valid=%v value=%q", len(got), utf8.ValidString(got), got)
|
||||
}
|
||||
}
|
||||
|
||||
type telegramLoginHTTPFixture struct {
|
||||
handler *Handler
|
||||
service *loginapp.Service
|
||||
credentials loginapp.ClientCredentials
|
||||
redirectURI string
|
||||
verifier string
|
||||
challenge string
|
||||
now *time.Time
|
||||
}
|
||||
|
||||
type telegramLoginHTTPDenyLimiter struct{}
|
||||
|
||||
func (telegramLoginHTTPDenyLimiter) Allow(context.Context, string, int, time.Duration) (bool, int, error) {
|
||||
return false, 17, nil
|
||||
}
|
||||
|
||||
func newTelegramLoginHTTPFixture(t *testing.T) telegramLoginHTTPFixture {
|
||||
t.Helper()
|
||||
now := time.Date(2026, 7, 20, 10, 0, 0, 0, time.UTC)
|
||||
sealKey := make([]byte, 32)
|
||||
sealKey[0] = 1
|
||||
sealer, err := loginapp.NewCodeSealer("test", map[string][]byte{"test": sealKey})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pepper := make([]byte, 32)
|
||||
pepper[0] = 2
|
||||
service, err := loginapp.NewService(memory.NewTelegramLoginStore(nil), sealer, loginapp.Config{
|
||||
Issuer: "https://oauth.telesrv.test", AppScheme: "telesrv", ClientSecretPepper: pepper,
|
||||
Now: func() time.Time { return now },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
credentials, err := service.CreateClient(context.Background(), 9001, domain.TelegramLoginSigningRS256)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const redirectURI = "https://rp.example/callback"
|
||||
if _, err := service.AddAllowedURL(context.Background(), 9001, domain.TelegramLoginAllowedRedirectURI, redirectURI); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := service.AddAllowedURL(context.Background(), 9001, domain.TelegramLoginAllowedWebOrigin, "https://rp.example"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
signingKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ring, err := loginapp.NewSigningKeyRing([]loginapp.SigningKeyMaterial{{
|
||||
Algorithm: domain.TelegramLoginSigningRS256, KeyID: "rsa-test", PrivateKey: signingKey, Active: true,
|
||||
}}, func() time.Time { return now })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tokens, err := loginapp.NewIDTokenIssuer(ring, loginapp.IDTokenIssuerConfig{
|
||||
Issuer: "https://oauth.telesrv.test", Now: func() time.Time { return now },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
handler, err := NewHandler(Config{Service: service, Tokens: tokens, AppName: "Telesrv", AllowLoopbackHTTP: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
|
||||
challenge, err := loginapp.PKCEChallenge(verifier)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return telegramLoginHTTPFixture{
|
||||
handler: handler, service: service, credentials: credentials, redirectURI: redirectURI,
|
||||
verifier: verifier, challenge: challenge, now: &now,
|
||||
}
|
||||
}
|
||||
|
||||
func (f telegramLoginHTTPFixture) authorize(t *testing.T) (browserToken, deepLink string) {
|
||||
t.Helper()
|
||||
query := url.Values{
|
||||
"client_id": {f.credentials.Client.ClientID}, "redirect_uri": {f.redirectURI},
|
||||
"response_type": {"code"}, "scope": {"openid profile phone telegram:bot_access"},
|
||||
"state": {"state-value"}, "nonce": {"nonce-value"}, "code_challenge": {f.challenge},
|
||||
"code_challenge_method": {"S256"},
|
||||
}
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "/auth?"+query.Encode(), nil)
|
||||
request.RemoteAddr = "192.0.2.10:4242"
|
||||
f.handler.ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("authorize status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
body := recorder.Body.String()
|
||||
tokenMatch := regexp.MustCompile(`const token=("[^"]+")`).FindStringSubmatch(body)
|
||||
if len(tokenMatch) != 2 || json.Unmarshal([]byte(tokenMatch[1]), &browserToken) != nil {
|
||||
t.Fatalf("browser token not found in page: %s", body)
|
||||
}
|
||||
deepLinkMatch := regexp.MustCompile(`href="([^"]+)"`).FindStringSubmatch(body)
|
||||
if len(deepLinkMatch) != 2 {
|
||||
t.Fatalf("deep link not found in page: %s", body)
|
||||
}
|
||||
deepLink = html.UnescapeString(deepLinkMatch[1])
|
||||
pending, err := f.service.RequestByDeepLink(context.Background(), deepLink)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve authorization page deep link: %v", err)
|
||||
}
|
||||
if pending.MatchCode == "" || !strings.Contains(body, `id="match-code"`) || !strings.Contains(body, pending.MatchCode) {
|
||||
t.Fatalf("matching emoji missing from authorization page: match=%q body=%s", pending.MatchCode, body)
|
||||
}
|
||||
if !strings.Contains(body, "poll();</script>") {
|
||||
t.Fatalf("authorization status polling is not started: %s", body)
|
||||
}
|
||||
return browserToken, deepLink
|
||||
}
|
||||
|
||||
func TestAuthorizationErrorsUseOnlyPreRegisteredTargets(t *testing.T) {
|
||||
f := newTelegramLoginHTTPFixture(t)
|
||||
base := url.Values{
|
||||
"client_id": {f.credentials.Client.ClientID}, "redirect_uri": {f.redirectURI},
|
||||
"response_type": {"code"}, "scope": {"openid unsupported"}, "state": {"safe-state"},
|
||||
"code_challenge": {f.challenge}, "code_challenge_method": {"S256"},
|
||||
}
|
||||
recorder := httptest.NewRecorder()
|
||||
f.handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/auth?"+base.Encode(), nil))
|
||||
if recorder.Code != http.StatusFound {
|
||||
t.Fatalf("valid redirect error status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
location, err := url.Parse(recorder.Header().Get("Location"))
|
||||
if err != nil || location.Scheme+"://"+location.Host+location.Path != f.redirectURI || location.Query().Get("error") != "invalid_scope" || location.Query().Get("state") != "safe-state" {
|
||||
t.Fatalf("error redirect=%q err=%v", recorder.Header().Get("Location"), err)
|
||||
}
|
||||
|
||||
forged := cloneURLValues(base)
|
||||
forged.Set("redirect_uri", "https://attacker.example/callback")
|
||||
recorder = httptest.NewRecorder()
|
||||
f.handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/auth?"+forged.Encode(), nil))
|
||||
if recorder.Code != http.StatusBadRequest || recorder.Header().Get("Location") != "" {
|
||||
t.Fatalf("forged redirect status=%d location=%q", recorder.Code, recorder.Header().Get("Location"))
|
||||
}
|
||||
|
||||
post := cloneURLValues(base)
|
||||
post.Set("redirect_uri", "https://rp.example/")
|
||||
post.Set("response_type", "post_message")
|
||||
post.Set("origin", "https://rp.example")
|
||||
recorder = httptest.NewRecorder()
|
||||
f.handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/auth?"+post.Encode(), nil))
|
||||
if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), `postMessage`) || !strings.Contains(recorder.Body.String(), `https://rp.example`) {
|
||||
t.Fatalf("post_message error status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
post.Set("redirect_uri", "https://attacker.example/")
|
||||
recorder = httptest.NewRecorder()
|
||||
f.handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/auth?"+post.Encode(), nil))
|
||||
if recorder.Code != http.StatusBadRequest {
|
||||
t.Fatalf("cross-origin post_message status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func cloneURLValues(in url.Values) url.Values {
|
||||
out := make(url.Values, len(in))
|
||||
for key, values := range in {
|
||||
out[key] = append([]string(nil), values...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (f telegramLoginHTTPFixture) approveAndFinalize(t *testing.T) (code string) {
|
||||
t.Helper()
|
||||
browserToken, deepLink := f.authorize(t)
|
||||
*f.now = f.now.Add(time.Second)
|
||||
pending, err := f.service.RequestByDeepLink(context.Background(), deepLink)
|
||||
if err != nil {
|
||||
t.Fatalf("RequestByDeepLink(%q): %v", deepLink, err)
|
||||
}
|
||||
_, _, err = f.service.Approve(context.Background(), deepLink, domain.TelegramLoginIdentitySnapshot{
|
||||
UserID: 42, Name: "Alice Example", GivenName: "Alice", FamilyName: "Example",
|
||||
PreferredUsername: "alice", Picture: "https://oauth.telesrv.test/userpic/42", PhoneNumber: "+1 555 123 4567",
|
||||
}, true, true, pending.MatchCode)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
form := url.Values{"browser_token": {browserToken}}
|
||||
recorder := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/auth/status", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
f.handler.ServeHTTP(recorder, req)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
var response struct {
|
||||
Status string `json:"status"`
|
||||
RedirectURL string `json:"redirect_url"`
|
||||
}
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil || response.Status != "approved" {
|
||||
t.Fatalf("status response=%+v err=%v body=%s", response, err, recorder.Body.String())
|
||||
}
|
||||
redirect, err := url.Parse(response.RedirectURL)
|
||||
if err != nil || redirect.Query().Get("state") != "state-value" {
|
||||
t.Fatalf("redirect=%q err=%v", response.RedirectURL, err)
|
||||
}
|
||||
return redirect.Query().Get("code")
|
||||
}
|
||||
|
||||
func TestDiscoveryAndJWKS(t *testing.T) {
|
||||
f := newTelegramLoginHTTPFixture(t)
|
||||
for _, path := range []string{"/.well-known/openid-configuration", "/.well-known/jwks.json"} {
|
||||
recorder := httptest.NewRecorder()
|
||||
f.handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, path, nil))
|
||||
if recorder.Code != http.StatusOK || recorder.Header().Get("X-Content-Type-Options") != "nosniff" {
|
||||
t.Fatalf("GET %s status=%d headers=%v body=%s", path, recorder.Code, recorder.Header(), recorder.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorizationCodeHTTPFlowAndReplay(t *testing.T) {
|
||||
f := newTelegramLoginHTTPFixture(t)
|
||||
code := f.approveAndFinalize(t)
|
||||
form := url.Values{
|
||||
"grant_type": {"authorization_code"}, "code": {code}, "redirect_uri": {f.redirectURI},
|
||||
"client_id": {f.credentials.Client.ClientID}, "code_verifier": {f.verifier},
|
||||
}
|
||||
exchange := func() *httptest.ResponseRecorder {
|
||||
recorder := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/token", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.SetBasicAuth(f.credentials.Client.ClientID, f.credentials.Secret)
|
||||
f.handler.ServeHTTP(recorder, req)
|
||||
return recorder
|
||||
}
|
||||
recorder := exchange()
|
||||
if recorder.Code != http.StatusOK || recorder.Header().Get("Cache-Control") != "no-store" {
|
||||
t.Fatalf("token status=%d headers=%v body=%s", recorder.Code, recorder.Header(), recorder.Body.String())
|
||||
}
|
||||
var response struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
IDToken string `json:"id_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int64 `json:"expires_in"`
|
||||
}
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil || response.AccessToken == "" || response.IDToken == "" || response.TokenType != "Bearer" || response.ExpiresIn != 3600 {
|
||||
t.Fatalf("token response=%+v err=%v body=%s", response, err, recorder.Body.String())
|
||||
}
|
||||
jwksRecorder := httptest.NewRecorder()
|
||||
f.handler.ServeHTTP(jwksRecorder, httptest.NewRequest(http.MethodGet, "/.well-known/jwks.json", nil))
|
||||
set, err := jwk.Parse(jwksRecorder.Body.Bytes())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
token, err := jwt.Parse([]byte(response.IDToken), jwt.WithKeySet(set), jwt.WithValidate(false))
|
||||
if err != nil || !token.Has("phone_number") || !token.Has("preferred_username") {
|
||||
t.Fatalf("verified ID token=%v err=%v", token, err)
|
||||
}
|
||||
replay := exchange()
|
||||
if replay.Code != http.StatusBadRequest || !strings.Contains(replay.Body.String(), "invalid_grant") {
|
||||
t.Fatalf("replay status=%d body=%s", replay.Code, replay.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeSDKCrossAppAndPublicPKCEExchange(t *testing.T) {
|
||||
f := newTelegramLoginHTTPFixture(t)
|
||||
const callbackURI = "bedolaga://telegram-login"
|
||||
if _, err := f.service.AddNativeApp(context.Background(), 9001, domain.TelegramLoginNativeIOS,
|
||||
"dev.bedolaga.demo", "ABCDE12345", callbackURI, "Bedolaga Demo"); err != nil {
|
||||
t.Fatalf("AddNativeApp: %v", err)
|
||||
}
|
||||
query := url.Values{
|
||||
"client_id": {f.credentials.Client.ClientID}, "redirect_uri": {callbackURI},
|
||||
"response_type": {"code"}, "scope": {"profile"}, "ios_sdk": {"1"},
|
||||
"code_challenge": {f.challenge}, "code_challenge_method": {"S256"},
|
||||
}
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "/crossapp?"+query.Encode(), nil)
|
||||
request.Header.Set("User-Agent", "TelegramLogin/iOS")
|
||||
f.handler.ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusOK || recorder.Header().Get("Cache-Control") != "no-store" {
|
||||
t.Fatalf("crossapp status=%d headers=%v body=%s", recorder.Code, recorder.Header(), recorder.Body.String())
|
||||
}
|
||||
var crossApp struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &crossApp); err != nil || crossApp.URL == "" {
|
||||
t.Fatalf("crossapp response=%+v err=%v", crossApp, err)
|
||||
}
|
||||
pending, err := f.service.RequestByDeepLink(context.Background(), crossApp.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if pending.Source != domain.TelegramLoginRequestNative || !pending.IsApp || pending.VerifiedAppName != "Bedolaga Demo" ||
|
||||
len(pending.Scopes) != 2 || pending.Scopes[0] != domain.TelegramLoginScopeOpenID {
|
||||
t.Fatalf("native request=%#v", pending)
|
||||
}
|
||||
*f.now = f.now.Add(time.Second)
|
||||
if _, _, err := f.service.Approve(context.Background(), crossApp.URL, domain.TelegramLoginIdentitySnapshot{
|
||||
UserID: 42, Name: "Alice", GivenName: "Alice",
|
||||
}, false, false, pending.MatchCode); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
redirectURL, err := f.service.FinalizeRedirectByDeepLink(context.Background(), crossApp.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
callback, err := url.Parse(redirectURL)
|
||||
if err != nil || callback.Scheme != "bedolaga" || callback.Host != "telegram-login" || callback.Query().Get("code") == "" {
|
||||
t.Fatalf("native callback=%q err=%v", redirectURL, err)
|
||||
}
|
||||
form := url.Values{
|
||||
"grant_type": {"authorization_code"}, "client_id": {f.credentials.Client.ClientID},
|
||||
"code": {callback.Query().Get("code")}, "redirect_uri": {callbackURI}, "code_verifier": {f.verifier},
|
||||
}
|
||||
tokenRecorder := httptest.NewRecorder()
|
||||
tokenRequest := httptest.NewRequest(http.MethodPost, "/token", strings.NewReader(form.Encode()))
|
||||
tokenRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
f.handler.ServeHTTP(tokenRecorder, tokenRequest)
|
||||
if tokenRecorder.Code != http.StatusOK || !strings.Contains(tokenRecorder.Body.String(), `"id_token"`) {
|
||||
t.Fatalf("native token status=%d body=%s", tokenRecorder.Code, tokenRecorder.Body.String())
|
||||
}
|
||||
|
||||
query.Set("android_sdk", "1")
|
||||
recorder = httptest.NewRecorder()
|
||||
f.handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/crossapp?"+query.Encode(), nil))
|
||||
if recorder.Code != http.StatusBadRequest {
|
||||
t.Fatalf("dual SDK marker status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicTokenAuthenticationCannotExchangeWebCode(t *testing.T) {
|
||||
f := newTelegramLoginHTTPFixture(t)
|
||||
code := f.approveAndFinalize(t)
|
||||
form := url.Values{
|
||||
"grant_type": {"authorization_code"}, "client_id": {f.credentials.Client.ClientID},
|
||||
"code": {code}, "redirect_uri": {f.redirectURI}, "code_verifier": {f.verifier},
|
||||
}
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPost, "/token", strings.NewReader(form.Encode()))
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
f.handler.ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusUnauthorized || !strings.Contains(recorder.Body.String(), "invalid_client") {
|
||||
t.Fatalf("public web exchange status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
|
||||
// Authentication failure happens before the one-time consume, so the same
|
||||
// code remains usable by the confidential web client.
|
||||
recorder = httptest.NewRecorder()
|
||||
request = httptest.NewRequest(http.MethodPost, "/token", strings.NewReader(form.Encode()))
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.SetBasicAuth(f.credentials.Client.ClientID, f.credentials.Secret)
|
||||
f.handler.ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("authenticated retry status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentTokenExchangeHasOneSuccess(t *testing.T) {
|
||||
f := newTelegramLoginHTTPFixture(t)
|
||||
code := f.approveAndFinalize(t)
|
||||
form := url.Values{
|
||||
"grant_type": {"authorization_code"}, "code": {code}, "redirect_uri": {f.redirectURI},
|
||||
"client_id": {f.credentials.Client.ClientID}, "code_verifier": {f.verifier},
|
||||
}.Encode()
|
||||
const workers = 8
|
||||
statuses := make(chan int, workers)
|
||||
var wg sync.WaitGroup
|
||||
for range workers {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
recorder := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/token", strings.NewReader(form))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.SetBasicAuth(f.credentials.Client.ClientID, f.credentials.Secret)
|
||||
f.handler.ServeHTTP(recorder, req)
|
||||
statuses <- recorder.Code
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
close(statuses)
|
||||
success := 0
|
||||
for status := range statuses {
|
||||
if status == http.StatusOK {
|
||||
success++
|
||||
} else if status != http.StatusBadRequest {
|
||||
t.Fatalf("unexpected concurrent exchange status %d", status)
|
||||
}
|
||||
}
|
||||
if success != 1 {
|
||||
t.Fatalf("successful exchanges=%d, want 1", success)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenEndpointSupportsBodySecretAndRejectsMixedAuthentication(t *testing.T) {
|
||||
f := newTelegramLoginHTTPFixture(t)
|
||||
code := f.approveAndFinalize(t)
|
||||
form := url.Values{
|
||||
"grant_type": {"authorization_code"}, "client_id": {f.credentials.Client.ClientID},
|
||||
"client_secret": {f.credentials.Secret}, "code": {code}, "redirect_uri": {f.redirectURI},
|
||||
"code_verifier": {f.verifier},
|
||||
}
|
||||
recorder := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/token", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
f.handler.ServeHTTP(recorder, req)
|
||||
if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), `"id_token"`) {
|
||||
t.Fatalf("client_secret_post status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
|
||||
secondCode := f.approveAndFinalize(t)
|
||||
form.Set("code", secondCode)
|
||||
recorder = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodPost, "/token", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.SetBasicAuth(f.credentials.Client.ClientID, f.credentials.Secret)
|
||||
f.handler.ServeHTTP(recorder, req)
|
||||
if recorder.Code != http.StatusBadRequest || !strings.Contains(recorder.Body.String(), "invalid_request") {
|
||||
t.Fatalf("mixed authentication status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
|
||||
recorder = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodPost, "/token", strings.NewReader(`{}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
f.handler.ServeHTTP(recorder, req)
|
||||
_, _ = io.Copy(io.Discard, recorder.Result().Body)
|
||||
if recorder.Code != http.StatusUnsupportedMediaType {
|
||||
t.Fatalf("JSON token content status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPRateLimitFailsBeforeAuthorizationCreation(t *testing.T) {
|
||||
f := newTelegramLoginHTTPFixture(t)
|
||||
f.handler.limiter = telegramLoginHTTPDenyLimiter{}
|
||||
recorder := httptest.NewRecorder()
|
||||
f.handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/auth", nil))
|
||||
if recorder.Code != http.StatusTooManyRequests || recorder.Header().Get("Retry-After") != "17" {
|
||||
t.Fatalf("rate limit status=%d headers=%v body=%s", recorder.Code, recorder.Header(), recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestJavaScriptPostMessageFlowReturnsStableDirectIDToken(t *testing.T) {
|
||||
f := newTelegramLoginHTTPFixture(t)
|
||||
query := url.Values{
|
||||
"client_id": {f.credentials.Client.ClientID}, "redirect_uri": {"https://rp.example/"},
|
||||
"response_type": {"post_message"}, "scope": {"openid profile phone"},
|
||||
"nonce": {"js-nonce"},
|
||||
}
|
||||
recorder := httptest.NewRecorder()
|
||||
f.handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/auth?"+query.Encode(), nil))
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("JS authorize status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
body := recorder.Body.String()
|
||||
tokenMatch := regexp.MustCompile(`const token=("[^"]+")`).FindStringSubmatch(body)
|
||||
deepLinkMatch := regexp.MustCompile(`href="([^"]+)"`).FindStringSubmatch(body)
|
||||
if len(tokenMatch) != 2 || len(deepLinkMatch) != 2 {
|
||||
t.Fatalf("JS authorize artifacts missing: %s", body)
|
||||
}
|
||||
var browserToken string
|
||||
if err := json.Unmarshal([]byte(tokenMatch[1]), &browserToken); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deepLink := html.UnescapeString(deepLinkMatch[1])
|
||||
pending, err := f.service.RequestByDeepLink(context.Background(), deepLink)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if pending.Source != domain.TelegramLoginRequestJavaScript || pending.Origin != "https://rp.example" || pending.CodeChallenge != "" {
|
||||
t.Fatalf("official JavaScript request = %#v", pending)
|
||||
}
|
||||
*f.now = f.now.Add(time.Second)
|
||||
if _, _, err := f.service.Approve(context.Background(), deepLink, domain.TelegramLoginIdentitySnapshot{
|
||||
UserID: 42, Name: "Alice Example", GivenName: "Alice", PhoneNumber: "15551234567",
|
||||
}, false, true, pending.MatchCode); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
poll := func() map[string]string {
|
||||
t.Helper()
|
||||
statusRecorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPost, "/auth/status", strings.NewReader(url.Values{"browser_token": {browserToken}}.Encode()))
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
f.handler.ServeHTTP(statusRecorder, request)
|
||||
if statusRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("JS status=%d body=%s", statusRecorder.Code, statusRecorder.Body.String())
|
||||
}
|
||||
var result map[string]string
|
||||
if err := json.Unmarshal(statusRecorder.Body.Bytes(), &result); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return result
|
||||
}
|
||||
first, second := poll(), poll()
|
||||
if first["status"] != "approved" || first["id_token"] == "" || second["id_token"] != first["id_token"] {
|
||||
t.Fatalf("direct token first=%v second=%v", first, second)
|
||||
}
|
||||
web, err := f.service.ListWebAuthorizations(context.Background(), 42)
|
||||
if err != nil || len(web) != 1 {
|
||||
t.Fatalf("direct web authorization=%#v err=%v", web, err)
|
||||
}
|
||||
if err := f.service.RevokeWebAuthorization(context.Background(), 42, web[0].Hash); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
revokedRecorder := httptest.NewRecorder()
|
||||
revokedRequest := httptest.NewRequest(http.MethodPost, "/auth/status", strings.NewReader(url.Values{"browser_token": {browserToken}}.Encode()))
|
||||
revokedRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
f.handler.ServeHTTP(revokedRecorder, revokedRequest)
|
||||
if revokedRecorder.Code != http.StatusInternalServerError || !strings.Contains(revokedRecorder.Body.String(), "server_error") {
|
||||
t.Fatalf("revoked direct delivery status=%d body=%s", revokedRecorder.Code, revokedRecorder.Body.String())
|
||||
}
|
||||
|
||||
exchangeForm := url.Values{
|
||||
"grant_type": {"authorization_code"}, "code": {first["id_token"]}, "redirect_uri": {"https://rp.example/"},
|
||||
"client_id": {f.credentials.Client.ClientID}, "code_verifier": {f.verifier},
|
||||
}
|
||||
exchangeRecorder := httptest.NewRecorder()
|
||||
exchangeRequest := httptest.NewRequest(http.MethodPost, "/token", strings.NewReader(exchangeForm.Encode()))
|
||||
exchangeRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
exchangeRequest.SetBasicAuth(f.credentials.Client.ClientID, f.credentials.Secret)
|
||||
f.handler.ServeHTTP(exchangeRecorder, exchangeRequest)
|
||||
if exchangeRecorder.Code != http.StatusBadRequest || !strings.Contains(exchangeRecorder.Body.String(), "invalid_grant") {
|
||||
t.Fatalf("direct token exchange status=%d body=%s", exchangeRecorder.Code, exchangeRecorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMiniAppOfficialInAppFlowIsOriginBoundAndOneTime(t *testing.T) {
|
||||
f := newTelegramLoginHTTPFixture(t)
|
||||
const origin = "https://rp.example"
|
||||
query := url.Values{
|
||||
"client_id": {f.credentials.Client.ClientID}, "scope": {"openid profile phone telegram:bot_access"},
|
||||
"origin": {origin}, "response_type": {"id_token"},
|
||||
}
|
||||
create := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "/inapp?"+query.Encode(), nil)
|
||||
request.Header.Set("Origin", origin)
|
||||
f.handler.ServeHTTP(create, request)
|
||||
if create.Code != http.StatusOK || create.Header().Get("Access-Control-Allow-Origin") != origin || create.Header().Get("Vary") != "Origin" {
|
||||
t.Fatalf("in-app create status=%d headers=%v body=%s", create.Code, create.Header(), create.Body.String())
|
||||
}
|
||||
var created struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
if err := json.Unmarshal(create.Body.Bytes(), &created); err != nil || created.URL == "" {
|
||||
t.Fatalf("in-app create response=%+v err=%v", created, err)
|
||||
}
|
||||
pending, err := f.service.RequestByDeepLinkForOrigin(context.Background(), created.URL, origin)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if pending.Source != domain.TelegramLoginRequestMiniApp || pending.InAppOrigin != origin || pending.ResponseType != "post_message" {
|
||||
t.Fatalf("in-app request=%#v", pending)
|
||||
}
|
||||
*f.now = f.now.Add(time.Second)
|
||||
if _, _, err := f.service.Approve(context.Background(), created.URL, domain.TelegramLoginIdentitySnapshot{
|
||||
UserID: 42, Name: "Alice Example", GivenName: "Alice", PhoneNumber: "15551234567",
|
||||
}, true, true, pending.MatchCode); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resultURL, err := f.service.FinalizeInAppRedirectByDeepLink(context.Background(), created.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parsed, err := url.Parse(resultURL)
|
||||
if err != nil || parsed.Scheme+"://"+parsed.Host != "https://oauth.telesrv.test" || parsed.Path != "/inapp" || parsed.Query().Get("token") == "" {
|
||||
t.Fatalf("in-app result URL=%q err=%v", resultURL, err)
|
||||
}
|
||||
token := parsed.Query().Get("token")
|
||||
|
||||
wrongOrigin := httptest.NewRecorder()
|
||||
wrongRequest := httptest.NewRequest(http.MethodGet, "/inapp?code="+url.QueryEscape(token), nil)
|
||||
wrongRequest.Header.Set("Origin", "https://attacker.example")
|
||||
f.handler.ServeHTTP(wrongOrigin, wrongRequest)
|
||||
if wrongOrigin.Code != http.StatusBadRequest {
|
||||
t.Fatalf("wrong-origin exchange status=%d body=%s", wrongOrigin.Code, wrongOrigin.Body.String())
|
||||
}
|
||||
|
||||
const workers = 8
|
||||
statuses := make(chan int, workers)
|
||||
results := make(chan string, workers)
|
||||
var wg sync.WaitGroup
|
||||
for range workers {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
recorder := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/inapp?code="+url.QueryEscape(token), nil)
|
||||
req.Header.Set("Origin", origin)
|
||||
f.handler.ServeHTTP(recorder, req)
|
||||
statuses <- recorder.Code
|
||||
results <- recorder.Body.String()
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
close(statuses)
|
||||
close(results)
|
||||
successes := 0
|
||||
responses := make([]string, 0, workers)
|
||||
for status := range statuses {
|
||||
if status == http.StatusOK {
|
||||
successes++
|
||||
} else if status != http.StatusBadRequest {
|
||||
t.Fatalf("unexpected in-app exchange status=%d", status)
|
||||
}
|
||||
}
|
||||
for response := range results {
|
||||
responses = append(responses, response)
|
||||
}
|
||||
if successes != 1 {
|
||||
t.Fatalf("in-app exchange successes=%d, want 1; responses=%v", successes, responses)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramLoginJavaScriptIsCacheableAndConditional(t *testing.T) {
|
||||
f := newTelegramLoginHTTPFixture(t)
|
||||
first := httptest.NewRecorder()
|
||||
f.handler.ServeHTTP(first, httptest.NewRequest(http.MethodGet, "/js/telegram-login.js", nil))
|
||||
if first.Code != http.StatusOK || first.Header().Get("ETag") == "" ||
|
||||
!strings.Contains(first.Body.String(), "Telegram.Login") ||
|
||||
!strings.Contains(first.Body.String(), "auth_result") ||
|
||||
!strings.Contains(first.Body.String(), "oauth_supported") ||
|
||||
!strings.Contains(first.Body.String(), "/inapp?") ||
|
||||
!strings.Contains(first.Body.String(), "data-client-id") {
|
||||
t.Fatalf("SDK status=%d headers=%v body=%s", first.Code, first.Header(), first.Body.String())
|
||||
}
|
||||
second := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "/js/telegram-login.js", nil)
|
||||
request.Header.Set("If-None-Match", first.Header().Get("ETag"))
|
||||
f.handler.ServeHTTP(second, request)
|
||||
if second.Code != http.StatusNotModified {
|
||||
t.Fatalf("conditional SDK status=%d", second.Code)
|
||||
}
|
||||
}
|
||||
90
internal/telegramloginhttp/sdk.go
Normal file
90
internal/telegramloginhttp/sdk.go
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
package telegramloginhttp
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// telegramLoginJavaScript intentionally follows the public Telegram Login
|
||||
// SDK contract (programmatic API, data-* auto-init, popup auth_result events,
|
||||
// and the Mini App oauth_* bridge). It derives the provider from its own
|
||||
// script origin so the same file works on a self-hosted issuer.
|
||||
const telegramLoginJavaScript = `(function(global){
|
||||
'use strict';
|
||||
var current=document.currentScript;
|
||||
if(!current){throw new Error('Telegram Login SDK must be loaded by a script element');}
|
||||
var provider=new URL(current.src,document.baseURI).origin;
|
||||
var saved=null,active=null,inApp=false,inAppPending=false;
|
||||
function callback(cb,value){if(typeof cb==='function'){try{cb(value);}catch(error){setTimeout(function(){throw error;},0);}}}
|
||||
function decode(token){try{var part=token.split('.')[1].replace(/-/g,'+').replace(/_/g,'/');while(part.length%4){part+='=';}return JSON.parse(decodeURIComponent(Array.from(atob(part),function(c){return '%'+c.charCodeAt(0).toString(16).padStart(2,'0');}).join('')));}catch(_){return null;}}
|
||||
function build(data){if(data&&data.error){return {error:String(data.error)};}var token=data&&data.result;if(typeof token!=='string'||!token){return {error:'missing id_token'};}var user=decode(token);return user?{id_token:token,user:user}:{error:'malformed id_token'};}
|
||||
function normalize(options){
|
||||
if(!options||!/^[0-9]{1,64}$/.test(String(options.client_id||''))){throw new Error('Telegram.Login client_id is required');}
|
||||
var scopes=['openid'],input=options.scope;
|
||||
if(input===undefined||input===null||input===''){scopes.push('profile');input=options.request_access||[];}
|
||||
if(typeof input==='string'){input=input.trim()?input.trim().split(/\s+/):[];}
|
||||
if(!Array.isArray(input)){throw new Error('Telegram.Login scope must be an array or string');}
|
||||
var allowed={profile:'profile',phone:'phone',write:'telegram:bot_access','telegram:bot_access':'telegram:bot_access'};
|
||||
input.forEach(function(value){var mapped=allowed[value];if(!mapped){throw new Error('Telegram.Login scope is invalid');}if(scopes.indexOf(mapped)<0){scopes.push(mapped);}});
|
||||
return {client_id:String(options.client_id),scope:scopes.join(' '),nonce:String(options.nonce||'').slice(0,1024),lang:String(options.lang||'').slice(0,16)};
|
||||
}
|
||||
function randomURL(bytes){var data=new Uint8Array(bytes);crypto.getRandomValues(data);var raw='';data.forEach(function(value){raw+=String.fromCharCode(value);});return btoa(raw).replace(/\+/g,'-').replace(/\//g,'_').replace(/=+$/,'');}
|
||||
function finish(flow,result){if(active!==flow){return;}active=null;if(flow.timer){clearInterval(flow.timer);}if(flow.listener){global.removeEventListener('message',flow.listener);}callback(flow.callback,result);}
|
||||
function sendEvent(type,data){if(global.TelegramWebviewProxy&&typeof global.TelegramWebviewProxy.postEvent==='function'){global.TelegramWebviewProxy.postEvent(type,JSON.stringify(data||{}));}}
|
||||
async function receiveEvent(type,data){
|
||||
if(type==='oauth_supported'){inApp=true;return;}
|
||||
if(type==='oauth_result_failed'){if(active){finish(active,{error:'access_denied'});}return;}
|
||||
if(type!=='oauth_result_confirmed'||!active||!data||!data.result_url){return;}
|
||||
try{var resultURL=new URL(data.result_url);if(resultURL.origin!==provider||resultURL.pathname!=='/inapp'){throw new Error('invalid in-app result URL');}var token=resultURL.searchParams.get('token');if(!token){throw new Error('missing in-app token');}
|
||||
var response=await fetch(provider+'/inapp?code='+encodeURIComponent(token),{credentials:'omit',cache:'no-store'}),result=await response.json();
|
||||
if(!response.ok){throw new Error(result.error||'in-app exchange failed');}finish(active,build(result));
|
||||
}catch(error){finish(active,{error:error.message||'in_app_failed'});}
|
||||
}
|
||||
function begin(options,cb,isNormalized){
|
||||
var normalized;try{normalized=isNormalized?options:normalize(options);}catch(error){callback(cb,{error:error.message});return null;}
|
||||
if(active){callback(cb,{error:'login_in_progress'});return null;}
|
||||
var flow={popup:null,callback:cb,listener:null,timer:null};active=flow;
|
||||
if(inApp){
|
||||
if(inAppPending){finish(flow,{error:'login_in_progress'});return null;}inAppPending=true;
|
||||
var inAppParams=new URLSearchParams({scope:normalized.scope,origin:global.location.origin,client_id:normalized.client_id,response_type:'id_token'});
|
||||
fetch(provider+'/inapp?'+inAppParams.toString(),{credentials:'omit',cache:'no-store'}).then(function(response){return response.json().then(function(body){if(!response.ok){throw new Error(body.error||'in-app request failed');}return body;});}).then(function(body){if(!body.url){throw new Error('missing OAuth deep link');}sendEvent('oauth_request',{url:body.url});}).catch(function(error){finish(flow,{error:error.message||'in_app_failed'});}).finally(function(){setTimeout(function(){inAppPending=false;},600);});
|
||||
return null;
|
||||
}
|
||||
var popup=global.open('about:blank','telegram-login-'+randomURL(8),'popup,width=550,height=650,resizable=yes,scrollbars=yes');
|
||||
if(!popup){finish(flow,{error:'popup_blocked'});return null;}flow.popup=popup;
|
||||
flow.listener=function(event){if(event.origin!==provider||event.source!==popup){return;}var data=event.data;try{if(typeof data==='string'){data=JSON.parse(data);}}catch(_){return;}if(!data||data.event!=='auth_result'){return;}finish(flow,build(data));};
|
||||
global.addEventListener('message',flow.listener);flow.timer=setInterval(function(){if(popup.closed){finish(flow,{error:'popup_closed'});}},500);
|
||||
try{var params=new URLSearchParams({client_id:normalized.client_id,redirect_uri:global.location.origin+global.location.pathname,response_type:'post_message',scope:normalized.scope});if(normalized.nonce){params.set('nonce',normalized.nonce);}if(normalized.lang){params.set('lang',normalized.lang);}popup.location.replace(provider+'/auth?'+params.toString());}
|
||||
catch(error){try{popup.close();}catch(_){}finish(flow,{error:error.message||'login_failed'});}
|
||||
return popup;
|
||||
}
|
||||
var api={
|
||||
init:function(options,cb){saved={options:normalize(options),callback:cb};return api;},
|
||||
open:function(cb){if(!saved){callback(cb,{error:'not_initialized'});return null;}return begin(saved.options,cb||saved.callback,true);},
|
||||
auth:function(options,cb){return begin(options,cb,false);},
|
||||
close:function(){if(active&&active.popup){try{active.popup.close();}catch(_){}}if(active){finish(active,{error:'popup_closed'});}}
|
||||
};
|
||||
global.Telegram=global.Telegram||{};global.Telegram.Login=api;
|
||||
global.Telegram.WebView=global.Telegram.WebView||{};global.Telegram.WebView.receiveEvent=receiveEvent;
|
||||
global.Telegram.TelegramGameProxy=global.Telegram.TelegramGameProxy||{};global.Telegram.TelegramGameProxy.receiveEvent=receiveEvent;
|
||||
if(global.TelegramWebviewProxy){sendEvent('oauth_request',{});}
|
||||
document.addEventListener('click',function(event){var node=event.target;while(node&&node!==document){if(node.classList&&node.classList.contains('tg-auth-button')){api.open();return;}node=node.parentNode;}});
|
||||
function resolveCallback(source){var match=/^([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)\s*\(\s*data\s*\)\s*;?$/.exec(source||'');if(!match){return null;}return function(data){var target=global,parts=match[1].split('.');for(var i=0;i<parts.length-1;i++){target=target&&target[parts[i]];}var fn=target&&target[parts[parts.length-1]];if(typeof fn==='function'){fn.call(target,data);}};}
|
||||
function autoInit(){var client=current.getAttribute('data-client-id');if(!client){return;}var options={client_id:client},access=current.getAttribute('data-request-access'),lang=current.getAttribute('data-lang');if(access){options.request_access=access.trim().split(/\s+/);}if(lang){options.lang=lang;}api.init(options,resolveCallback(current.getAttribute('data-onauth')));}
|
||||
if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',autoInit);}else{autoInit();}
|
||||
})(window);`
|
||||
|
||||
func (h *Handler) loginJavaScript(w http.ResponseWriter, r *http.Request) {
|
||||
sum := sha256.Sum256([]byte(telegramLoginJavaScript))
|
||||
etag := `"` + base64.RawURLEncoding.EncodeToString(sum[:]) + `"`
|
||||
w.Header().Set("Content-Type", "text/javascript; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "public, max-age=3600, must-revalidate")
|
||||
w.Header().Set("ETag", etag)
|
||||
if r.Header.Get("If-None-Match") == etag {
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(telegramLoginJavaScript))
|
||||
}
|
||||
|
|
@ -33,6 +33,10 @@ type Config struct {
|
|||
Photos ProfilePhotoResolver
|
||||
UniqueGifts UniqueStarGiftResolver
|
||||
GiftWithdrawals StarGiftWithdrawalResolver
|
||||
// TelegramLogin is the optional OIDC/Login HTTP adapter. Public Web owns
|
||||
// the listener so discovery/auth/token and public links share the exact
|
||||
// externally registered origin behind one reverse proxy.
|
||||
TelegramLogin http.Handler
|
||||
}
|
||||
|
||||
type StickerSetResolver interface {
|
||||
|
|
@ -166,6 +170,17 @@ func newHandler(cfg Config, logger *zap.Logger) (http.Handler, error) {
|
|||
mux.HandleFunc("GET /nft/{slug}/{$}", h.uniqueGift)
|
||||
mux.HandleFunc("GET /gift-withdrawal/{requestID}", h.starGiftWithdrawal)
|
||||
mux.HandleFunc("POST /gift-withdrawal/{requestID}", h.completeStarGiftWithdrawal)
|
||||
if cfg.TelegramLogin != nil {
|
||||
mux.Handle("GET /.well-known/openid-configuration", cfg.TelegramLogin)
|
||||
mux.Handle("GET /.well-known/jwks.json", cfg.TelegramLogin)
|
||||
mux.Handle("GET /auth", cfg.TelegramLogin)
|
||||
mux.Handle("GET /crossapp", cfg.TelegramLogin)
|
||||
mux.Handle("GET /inapp", cfg.TelegramLogin)
|
||||
mux.Handle("POST /auth/status", cfg.TelegramLogin)
|
||||
mux.Handle("POST /token", cfg.TelegramLogin)
|
||||
mux.Handle("GET /telegram-login.js", cfg.TelegramLogin)
|
||||
mux.Handle("GET /js/telegram-login.js", cfg.TelegramLogin)
|
||||
}
|
||||
mux.HandleFunc("GET /{username}", h.usernameLink)
|
||||
mux.HandleFunc("GET /{username}/{$}", h.usernameLink)
|
||||
return publicSecurityHeaders(mux), nil
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue